Repository: cogent3/cogent3 (language: python)

Issue:
DEV: tidy

ENH: Prep for plugin infrastructure
Define all cogent3 apps as plugins. Using `stevedore` for the plugin management, specify all builtin cogent3 apps as part of the `cogent3.app` name space.

This patch should be pushed through to a new branch, perhaps called `cogent3-plugin`.

## step 1

The process for creating the `pyproject.toml` entries can be done (in the first instance) based on the following
```python
from cogent3 import available_apps

apps = available_apps()
apps = apps.get_columns(["module", "name"])
apps = apps.with_new_column("entry_point", lambda x: f"{x[1]} = {x[0]}:{x[1]}")
print("\n".join(apps.to_list("entry_point")))
```
These will be added under the `[project...]` section as we did in the meeting.

## step 2
Remove registration from `cogent3.appdefine_app` and delete `cogent3.app._app_registry`. They should be replaced with the stevedore `ExtensionManager(namespace="cogent3.app", invoke_on_load=False)`. The entry manager instance should be created in `cogent3.app.__init__.py`.

## step 3
The `cogent3.app.get_app()` and `cogent3.app.app_help()` functions can then just use the extension manager instance to access the app as the `<extension>.plugin`.

I'm not sure if two separate third-party tools could define an app with the same name, if so we need to modify the lookup sections.

## step 4
`available_apps()` is modified to use the extension manager.
remove code marked for deprecation
Dependent on merge of #1827 
Bug: Replace pkg_resources with import_lib
`pkg_resources` is deprecated from python 3.12 on.  Replace with `import_lib` in the app registration code
cookbook for apps
Apps need documentation!! This should be structured based on the type of apps, i.e., loaders, filtering steps, data sampling apps. 

- reorganise the current app documentation

### todo: cookbook
- [x] io
- [ ] dist
- [ ] tree 
- [ ] align
- [ ] evo 
- [ ] translate 
- [x] sample 

### todo: docstrings
- [ ] io
- [ ] dist
- [ ] tree 
- [ ] align
- [ ] evo 
- [x] translate 
- [x] sample 

`doctest-modules` doesn't recognise docstring examples in decorated functions
Running ``pytest --doctest-modules -v`` doesn't find docstrings in functions decorated with `@define_app` (e.g. `app.align.cogent3_score`). 

However, it works fine in `@define_app` classes where the docstrings are in the ``__init__`` (e.g. `app.align.progressive_align`).

Specfically, `composable._class_from_func` does not seem to be correctly propagating docstrings.


cookbook for apps
Apps need documentation!! This should be structured based on the type of apps, i.e., loaders, filtering steps, data sampling apps. 

- reorganise the current app documentation

### todo: cookbook
- [x] io
- [ ] dist
- [ ] tree 
- [ ] align
- [ ] evo 
- [ ] translate 
- [x] sample 

### todo: docstrings
- [ ] io
- [ ] dist
- [ ] tree 
- [ ] align
- [ ] evo 
- [x] translate 
- [x] sample 



Discussion / hints:

__app_registry had some data that isn't available to stevedore, namely whether the app is composable (ie: has been decorated by @define_app, as a hack I am using the following to replicate the same functionality (checking if it has the attribute "app_type")  

```Python
def _get_app_attr(name):
    """
    returns app details for display
    NB: this loads the module the app is in
    """

    obj = apps()[name].plugin

    # TODO: check this is the best way to determine if an app is composable
    is_composable = hasattr(obj, "app_type")

    _types = _make_types(obj)

    return [
        obj.__module__,
        name,
        is_composable,
        _doc_summary(obj.__doc__ or ""),
        ", ".join(sorted(_types["_data_types"])),
        ", ".join(sorted(_types["_return_types"])),
    ]

```

I just want to verify that there isn't a better strategy, or if I am opening myself up to unexpected side effects.
2 tests have been marked with @pytest.mark.xfail(reason="Constructing apps on the fly is no longer supported") because to create dummy apps we have to have a dummy project and install it, run the test, then uninstall it.  Doesn't seem an easy way to mock this that will actually test get_app or app_help on a new test app.

```Python

@pytest.mark.xfail(reason="Constructing apps on the fly is no longer supported")
@pytest.mark.parametrize(
    "app_doc,init_doc", ((None, None), ("text", None), (None, "text"), ("text", "text"))
)
def test_app_help_no_docs(capsys, app_doc, init_doc):
```
and 
```Python
@pytest.mark.xfail(reason="Constructing apps on the fly is no longer supported")
def test_app_help_doctest_examples(capsys):
```

both in test_app_doctest_examples
ran into the side effects of the case of the value in AppType and not using an enum,  The following is my solution 

```Python
def is_composable(obj):
    """checks whether obj has been registered by the composable decorator"""
    return hasattr(obj, "app_type") and obj.app_type != AppType.NON_COMPOSABLE
```


related to #1378 
Following up on how to include code snippets, here is the [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html#examples) description of how they should be formatted.
Running `pytest` catches the code snippets in a simple example by adding:

- The code snippet in the docstring, with a pass and fail conditions
https://github.com/fredjaya/DemoProject/blob/ea776bc185b1cd252068619e2f0862a0d34b7c78/src/myproject/cli.py#L94-L101

- doctest options in the .toml
https://github.com/fredjaya/DemoProject/blob/ea776bc185b1cd252068619e2f0862a0d34b7c78/pyproject.toml?plain=1#L53-L55

Output is:
```
================ test session starts =================
platform linux -- Python 3.11.6, pytest-7.4.3, pluggy-1.3.0
rootdir: /home/frederickjaya/GitHub/DemoProject
configfile: pyproject.toml
testpaths: tests, src/myproject
plugins: anyio-4.1.0, cov-4.1.0, xdist-3.5.0
collected 3 items                                    

tests/test_myproject.py ..                     [ 66%]
src/myproject/cli.py F                         [100%]

====================== FAILURES ======================
_______ [doctest] myproject.cli._parse_csv_arg _______
095 
096     >>> _parse_csv_arg("test")
097     ['test']
098     >>> _parse_csv_arg("test")
Expected:
    ['fail']
Got:
    ['test']

/home/frederickjaya/mambaforge/envs/c311/lib/python3.11/site-packages/myproject/cli.py:98: DocTestFailure
============== short test summary info ===============
FAILED src/myproject/cli.py::myproject.cli._parse_csv_arg

```
Thanks @fredjaya ! I'm going to create another issue to trial this for one of the apps.

related to #1378 
Following up on how to include code snippets, here is the [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html#examples) description of how they should be formatted.
Running `pytest` catches the code snippets in a simple example by adding:

- The code snippet in the docstring, with a pass and fail conditions
https://github.com/fredjaya/DemoProject/blob/ea776bc185b1cd252068619e2f0862a0d34b7c78/src/myproject/cli.py#L94-L101

- doctest options in the .toml
https://github.com/fredjaya/DemoProject/blob/ea776bc185b1cd252068619e2f0862a0d34b7c78/pyproject.toml?plain=1#L53-L55

Output is:
```
================ test session starts =================
platform linux -- Python 3.11.6, pytest-7.4.3, pluggy-1.3.0
rootdir: /home/frederickjaya/GitHub/DemoProject
configfile: pyproject.toml
testpaths: tests, src/myproject
plugins: anyio-4.1.0, cov-4.1.0, xdist-3.5.0
collected 3 items                                    

tests/test_myproject.py ..                     [ 66%]
src/myproject/cli.py F                         [100%]

====================== FAILURES ======================
_______ [doctest] myproject.cli._parse_csv_arg _______
095 
096     >>> _parse_csv_arg("test")
097     ['test']
098     >>> _parse_csv_arg("test")
Expected:
    ['fail']
Got:
    ['test']

/home/frederickjaya/mambaforge/envs/c311/lib/python3.11/site-packages/myproject/cli.py:98: DocTestFailure
============== short test summary info ===============
FAILED src/myproject/cli.py::myproject.cli._parse_csv_arg

```
Thanks @fredjaya ! I'm going to create another issue to trial this for one of the apps.

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/deprecation.md b/.github/ISSUE_TEMPLATE/deprecation.md
index dd774623e..5fa83d045 100644
--- a/.github/ISSUE_TEMPLATE/deprecation.md
+++ b/.github/ISSUE_TEMPLATE/deprecation.md
@@ -21,9 +21,10 @@ What should it be replaced with and why?
 
 **How much notice**
 
-The number of months (*x*) long before the capability will be removed? This should be represented in the user warning as a specific version. e.g. <current year>.<current month + *x*> where *x* is from the PR date.  
+**How much notice**
 
-**Guidelines for deprecation**
+How much time should the community be given to response to this notice? Specify as the number of months (*x*) before the capability will be removed. Leave blank if not known.
 
-As this is a function being deprecated, see [dev guidelines for deprecation](https://github.com/cogent3/c3dev/wiki/Deprecating-Code) of code. Indicate the limiting version (for when the old function will be removed) as 3 months from when you make the change.
+**Guidelines for deprecation**
 
+As this is a function that will be removed, see [dev guidelines for deprecation](https://github.com/cogent3/c3dev/wiki/Deprecating-Code) of code. Indicate the limiting version (for when the old function will be removed) as <current year>.<PR month + *x*> where *x* is defined above.
diff --git a/.github/ISSUE_TEMPLATE/discontinue.md b/.github/ISSUE_TEMPLATE/discontinue.md
index 28740a010..8f82f7e26 100644
--- a/.github/ISSUE_TEMPLATE/discontinue.md
+++ b/.github/ISSUE_TEMPLATE/discontinue.md
@@ -21,9 +21,8 @@ Why should it be discontinued?
 
 **How much notice**
 
-The number of months (*x*) long before the capability will be removed? This should be represented in the user warning as a specific version. e.g. <current year>.<current month + *x*> where *x* is from the PR date.  
+How much time should the community be given to response to this notice? Specify as the number of months (*x*) before the capability will be removed. Leave blank if not known.
 
 **Guidelines for deprecation**
 
-As this is a function that will be removed, see [dev guidelines for deprecation](https://github.com/cogent3/c3dev/wiki/Deprecating-Code) of code. Indicate the limiting version (for when the old function will be removed) as 3 months from when you make the change.
-
+As this is a function that will be removed, see [dev guidelines for deprecation](https://github.com/cogent3/c3dev/wiki/Deprecating-Code) of code. Indicate the limiting version (for when the old function will be removed) as <current year>.<PR month + *x*> where *x* is defined above.  
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e55c9a689..940764d2f 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -6,3 +6,9 @@ updates:
       interval: weekly
       time: "19:00"
     open-pull-requests-limit: 10
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      time: "19:00"
+    open-pull-requests-limit: 10
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index b35ed5c7f..8cca1ba77 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -39,11 +39,11 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
+      uses: github/codeql-action/init@v3
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -57,7 +57,7 @@ jobs:
     # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
-      uses: github/codeql-action/autobuild@v2
+      uses: github/codeql-action/autobuild@v3
 
     # ℹ️ Command-line programs to run using the OS shell.
     # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -70,6 +70,6 @@ jobs:
     #   ./location_of_script_within_repo/buildscript.sh
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
+      uses: github/codeql-action/analyze@v3
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml
index bfaaad9d2..432824952 100644
--- a/.github/workflows/linters.yml
+++ b/.github/workflows/linters.yml
@@ -12,11 +12,11 @@ on:
 jobs:
   linters:
     runs-on: ubuntu-latest
-    if: github.repository == 'cogent/cogent3'
+    if: github.repository == 'cogent3/cogent3'
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: '3.11'
       - name: Install dependencies
@@ -33,7 +33,7 @@ jobs:
       - name: Format code using black
         run: black .
       - name: Sort import statements using isort
-        run: isort
+        run: isort .
       - name: Commit changes
         uses: EndBug/add-and-commit@v9
         with:
diff --git a/.gitignore b/.gitignore
index 3bf432410..3bd13a6e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,9 @@ lib
 lib64
 doc/_build
 
+# docker stuff
+.devcontainer/*
+
 # Installer logs
 pip-log.txt
 
@@ -31,6 +34,7 @@ junit-*.xml
 nosetests.xml
 tests/draw_results
 lcov*.info
+.ruff_cache/*
 
 # Translations
 *.mo
@@ -48,5 +52,8 @@ __pycache__
 .vscode/*
 venv/
 working/
+*.ipynb
+*.ipynb_checkpoints/
+
 # vi
 .*.swp
diff --git a/.hgignore b/.hgignore
index 0029edadb..fed9764e5 100644
--- a/.hgignore
+++ b/.hgignore
@@ -20,6 +20,7 @@ draw_results
 *.wpu
 .cache*
 *taskpaper
+*.ipynb
 *.ipynb_checkpoints*
 *.sublime*
 *.patch
@@ -34,4 +35,7 @@ junit-*.xml
 doc/draw*
 dist/*
 working/*
-lcov*.info
\ No newline at end of file
+lcov*.info
+.ruff_cache/*
+.devcontainer/*
+hg_old.zip
\ No newline at end of file
diff --git a/.hgtags b/.hgtags
index e6b23afc6..c13456379 100644
--- a/.hgtags
+++ b/.hgtags
@@ -36,3 +36,5 @@ a82da4d3716db1c03ddf7d4bb2e3f5e2a5a143d1 develop
 91ccb723aa378107f80ac0fe8cb6f65e94f7833a 2023.7.18a1
 d77104f69e616ed3b077188540901cdb5d968966 2023.9.22a1
 0201d844deb847beceedefd288d82d235766c617 2023.12.15a1
+b8b3912265bd186f28d9ba90d79acac37f09d158 2024.2.5a1
+a726f3d6e29e43a9f7a114b066d146e8e8c62f92 2024.5.7a1
diff --git a/changelog.md b/changelog.md
index 0071bbf6f..858b33be3 100755
--- a/changelog.md
+++ b/changelog.md
@@ -1,4 +1,133 @@
 
+<a id='changelog-2024.5.7a1'></a>
+# Changes in release "2024.5.7a1"
+
+## Contributors
+
+- @KatherineCaley for reviewing PRs and code contributions
+  related to SeqView taking on a more prominent role in recording
+  history of operations on sequences.
+- First-time contributions from @Yutong-Shao, @Zongjing-Han, @yantonglu,
+  @ShunyiYang, @KyleSu12, @Ruizhe-wang, @Firimp 🎉
+- Adopted suggested documentation example proposed by Lizhaozhe123,
+  with some amendments, for the trim_stop_codons app.
+- Multiple contributions from @GavinHuttley
+- @rmcar17 identified and fixed a bug 🚀!
+- @rmcar17 addressed deprecation of using `end` as a column name
+- Implementation of plug-in support :building_construction: by @khiron
+- We had many contributors to project maintenance, making
+  the contributor experience better 🎉!!
+  @rmcar17, @GavinHuttley @fredjaya reviewed PR's 🛠️🚀
+  @khiron, @KatherineCaley, @YapengLang, @fredjaya, @rmcar17
+  and @GavinHuttley all commented on Issues.
+  @khiron, @fredjaya and @rmcar17 contributed to general
+  maintenance tasks, including developer docs and troubleshooting
+  infrastructure issues.
+
+## ENH
+
+- Annotation databases are now preserved after renaming sequences.
+  This is made possible because SeqView has the seqid attribute which
+  is preserved and is independent of the name of the enclosing Sequence.
+- Sequence.parent_coordinates() now returns information related to its
+  parent. For example, we have a sequence with name "1" which we then
+  sliced and rename the slice to "gene". Calling gene.parent_coordinates()
+  would return "1", slice start, slice end, strand.
+- methods on likelihood function objects that work with continuous-time
+  Markov models no longer fail if the model also has discrete-time edges.
+  These include lf.get_lengths_as_ens(), lf.get_annotated_tree(),
+  lf.get_paralinear_metric().
+- Added new lf.get_ens_tree(). This returns trees with the expected number
+  of substitutions as the branch length. On discrete-time edges the branch
+  length is set to None. Think of this tree as the true "evolutionary tree".
+  Thanks to Von Bing Yap for suggesting this!
+- Plugin support that will allow 3rd-party developers to add their custom
+  functionality to the cogent3 pipeline as first class apps
+- The new IndelMap class uses numpy arrays to store information about gap
+  locations for aligned sequences. This will greatly reduce the memory
+  overhead for aligned sequences. The class also provides explicit methods
+  for inter-converting between sequence and alignment coordinates. An important
+  difference to the original Map implementation is that IndelMap is memoryless,
+  meaning the history of slice operations is now fully delegated to the SeqView
+  class.
+- The new FeatureMap class is a slightly modified version of the original Map
+  class. It is used solely for handling sequence feature mappings to sequences
+  and alignments. Like IndelMap, it is memoryless but it does record its
+  orientation with respect to the parent sequence.
+- Note that both IndelMap and FeatureMap replace the spans attribute with a
+  generator.
+
+## BUG
+
+- The saving of numpy arrays in annotation db's was not cross-platform
+  compatible due to differences in default types between OS's. Fixed by
+  using numpy.save and numpy.load. A warning is raised if an old style
+  format is detected and a function provided for updating the format.
+- Alignment.quick_tree() default settings referred to a distance calculator
+  that has been removed. Now updated to the pdist calculator.
+
+## DOC
+
+- added cookbook docs for io apps
+
+## Deprecations
+
+- `end` is a SQL keyword and so should be avoided. It is now replaced
+  by `stop`. Annotation db's with the old column name are dynamically
+  updated.
+
+## Discontinued
+
+- The cogent3.core.location.Map class is now marked for deprecation. It is
+  being replaced by two classes, IndelMap and FeatureMap. The latter has
+  largely the same functionality of Map.
+
+<a id='changelog-2024.2.5a1'></a>
+# Changes in release "2024.2.5a1"
+
+## Contributors
+
+- @fredjaya, documentation, bug fixes and tests. Thanks @fredjaya 🚀!!
+- @KatherineCaley and @khiron, maintenance 🛠️. Thanks @KatherineCaley and @khiron 🏎️!
+- @GavinHuttley, miscellaneous 🍥
+
+## ENH
+
+- Now support python3.12 🚀
+- Pin numpy version to < 2 until we can test against released version.
+- AnnotationDb.subset() method. Returns a new instance matching provided
+  conditions.
+- SeqView.parent_start, SeqView.parent_stop properties report position
+  on original sequence. These help keep track of the segment on original
+  after slicing operations. They always **return** plus strand orientation.
+- SeqView offset argument added to constructor
+- SeqView.copy() method. Supports slicing the original data (and
+  recording the offset).
+- Sequence.parent_coordinates() returns parent_start, parent_stop, strand
+  values from underlying SeqView. strand is +/- 1, which is relative to
+  the original sequence.
+- AnnotationDb methods now expect the strand argument to have value "+"/"-"
+  or None.
+- make_seq() returns a seq as is if it's already the correct molecular
+  type. This preserves the AnnotationDb attribute.
+
+## BUG
+
+- Fixed ArrayAlignment.get_degapped_relative_to(). It was returning the
+  transpose of the alignment.
+
+## DOC
+
+- Improvements to docstrings for some cogent3.apps. We have added code
+  snippets that can be copy/paste into a python session.
+- Major updates to the developer docs to guide new contributors 🎉! Check
+  them out [at the c3dev wiki](https://github.com/cogent3/c3dev/wiki).
+
+## Deprecations
+
+- We drop support for python3.8
+- Assorted arguments marked for deprecation
+
 <a id='changelog-2023.12.15a1'></a>
 # Changes in release "2023.12.15a1"
 
diff --git a/doc/app/align-codon.rst b/doc/app/app_cookbook/align-codon.rst
similarity index 96%
rename from doc/app/align-codon.rst
rename to doc/app/app_cookbook/align-codon.rst
index 41e1e6d5f..e780a742c 100644
--- a/doc/app/align-codon.rst
+++ b/doc/app/app_cookbook/align-codon.rst
@@ -9,6 +9,7 @@ Using a codon model
 We load the unaligned sequences we will use in our examples.
 
 .. jupyter-execute::
+    :raises:
 
     from cogent3 import get_app
 
@@ -23,6 +24,7 @@ Codon alignment with default settings
 The default settings will result in estimation of a guide tree (using percent identity between the sequences). The default "codon" model is MG94HKY.
 
 .. jupyter-execute::
+    :raises:
 
     from cogent3 import get_app
 
@@ -40,6 +42,7 @@ The distance measures available are the same as for the nucleotide case (percent
 .. note:: An estimated guide tree has its branch lengths scaled so they are consistent with usage in a codon model.
 
 .. jupyter-execute::
+    :raises:
 
     nt_aligner = get_app("progressive_align", "codon", distance="paralinear")
     aligned = nt_aligner(seqs)
@@ -49,6 +52,7 @@ Providing a guide tree
 ----------------------
 
 .. jupyter-execute::
+    :raises:
 
     tree = "((Chimp:0.001,Human:0.001):0.0076,Macaque:0.01,((Rat:0.01,Mouse:0.01):0.02,Mouse_Lemur:0.02):0.01)"
     codon_aligner = get_app("progressive_align", "codon", guide_tree=tree)
@@ -61,6 +65,7 @@ Specifying the gap parameters
 -----------------------------
 
 .. jupyter-execute::
+    :raises:
 
     codon_aligner = get_app("progressive_align",
         "codon", guide_tree=tree, indel_rate=0.001, indel_length=0.01
@@ -74,6 +79,7 @@ Specifying the substitution model and parameters
 Any ``cogent3`` codon substitution model can be used. (See ``cogent3.available_models()``.)
 
 .. jupyter-execute::
+    :raises:
 
     codon_aligner = get_app("progressive_align",
         "CNFHKY", guide_tree=tree, param_vals=dict(omega=0.1, kappa=3)
@@ -89,5 +95,6 @@ Alignment settings and file provenance are recorded in the ``info`` attribute
 The parameters used to construct the alignment, including the guide tree and substitution model, are record in the alignment ``info`` attribute.
 
 .. jupyter-execute::
+    :raises:
 
     aligned.info
diff --git a/doc/app/align-nucleotide.rst b/doc/app/app_cookbook/align-nucleotide.rst
similarity index 96%
rename from doc/app/align-nucleotide.rst
rename to doc/app/app_cookbook/align-nucleotide.rst
index 706919c86..194765efe 100644
--- a/doc/app/align-nucleotide.rst
+++ b/doc/app/app_cookbook/align-nucleotide.rst
@@ -9,6 +9,7 @@ Using a nucleotide model
 We load the unaligned sequences we will use in our examples.
 
 .. jupyter-execute::
+    :raises:
 
     from cogent3 import get_app
 
@@ -23,6 +24,7 @@ Nucleotide alignment with default settings
 The default setting for "nucleotide" is a HKY85 model.
 
 .. jupyter-execute::
+    :raises:
 
     from cogent3 import get_app
 
@@ -38,6 +40,7 @@ Specify a different distance measure for estimating the guide tree
 For the nucleotide case, you can use TN93 or paralinear.
 
 .. jupyter-execute::
+    :raises:
 
     nt_aligner = get_app("progressive_align", "nucleotide", distance="TN93")
     aligned = nt_aligner(seqs)
@@ -47,6 +50,7 @@ Providing a guide tree
 ----------------------
 
 .. jupyter-execute::
+    :raises:
 
     tree = "((Chimp:0.001,Human:0.001):0.0076,Macaque:0.01,((Rat:0.01,Mouse:0.01):0.02,Mouse_Lemur:0.02):0.01)"
     nt_aligner = get_app("progressive_align", "nucleotide", guide_tree=tree)
@@ -61,6 +65,7 @@ Specifying the substitution model
 You can use any ``cogent3`` nucleotide substitution model. For a list of all available, see ``cogent3.available_models()``.
 
 .. jupyter-execute::
+    :raises:
 
     tree = "((Chimp:0.001,Human:0.001):0.0076,Macaque:0.01,((Rat:0.01,Mouse:0.01):0.02,Mouse_Lemur:0.02):0.01)"
     nt_aligner = get_app("progressive_align", "F81", guide_tree=tree)
@@ -71,5 +76,6 @@ Alignment settings and file provenance are recorded in the ``info`` attribute
 -----------------------------------------------------------------------------
 
 .. jupyter-execute::
+    :raises:
 
     aligned.info
diff --git a/doc/app/align-protein.rst b/doc/app/app_cookbook/align-protein.rst
similarity index 96%
rename from doc/app/align-protein.rst
rename to doc/app/app_cookbook/align-protein.rst
index 55f517e4a..51539773d 100644
--- a/doc/app/align-protein.rst
+++ b/doc/app/app_cookbook/align-protein.rst
@@ -9,6 +9,7 @@ Using a protein model
 We use apps to load unaligned DNA sequences and to translate them into amino acids.
 
 .. jupyter-execute::
+    :raises:
 
     from cogent3 import get_app
 
@@ -23,6 +24,7 @@ Protein alignment with default settings
 The default setting for "protein" is a WG01 model.
 
 .. jupyter-execute::
+    :raises:
 
     from cogent3 import get_app
 
@@ -38,6 +40,7 @@ The distance measures available are percent or paralinear.
 .. note:: An estimated guide tree has its branch lengths scaled so they are consistent with usage in a codon model.
 
 .. jupyter-execute::
+    :raises:
 
     aa_aligner = get_app("progressive_align", "protein", distance="paralinear")
     aligned = aa_aligner(seqs)
@@ -47,5 +50,6 @@ Alignment settings and file provenance are recorded in the ``info`` attribute
 -----------------------------------------------------------------------------
 
 .. jupyter-execute::
+    :raises:
 
     aligned.info
diff --git a/doc/app/index-align.rst b/doc/app/app_cookbook/index-align.rst
similarity index 95%
rename from doc/app/index-align.rst
rename to doc/app/app_cookbook/index-align.rst
index b6e040427..fe8cdfb6a 100644
--- a/doc/app/index-align.rst
+++ b/doc/app/app_cookbook/index-align.rst
@@ -5,7 +5,6 @@ Sequence Alignment
 
 .. toctree::
     :maxdepth: 1
-    :hidden:
 
     align-nucleotide
     align-codon
diff --git a/doc/app/app_cookbook/index-loaders.rst b/doc/app/app_cookbook/index-loaders.rst
new file mode 100644
index 000000000..31a73b936
--- /dev/null
+++ b/doc/app/app_cookbook/index-loaders.rst
@@ -0,0 +1,12 @@
+Loading data
+============
+
+Apps with the type ``"loader"`` will load data from file(s). When composed into a process, loaders can be applied in batch to multiple data files. 
+
+.. toctree::
+    :maxdepth: 1
+
+    load-aligned
+    load-unaligned
+    load-tabular
+    load-json
\ No newline at end of file
diff --git a/doc/app/app_cookbook/index-sample.rst b/doc/app/app_cookbook/index-sample.rst
new file mode 100644
index 000000000..80abf6833
--- /dev/null
+++ b/doc/app/app_cookbook/index-sample.rst
@@ -0,0 +1,19 @@
+Filtering data 
+==============
+
+``cogent3`` apps provide simple and sophisticated means to filter genomic data.
+
+.. toctree::
+    :maxdepth: 1
+
+    sample-omit-degen
+    sample-omit-gap
+    sample-omit-bad
+    sample-omit-duplicated
+    sample-min-len
+    sample-fixed-len
+    sample-trim-stop
+    sample-take-pos
+    sample-take-seqs
+    sample-take-n-seqs
+    sample-concat
diff --git a/doc/app/app_cookbook/index-writers.rst b/doc/app/app_cookbook/index-writers.rst
new file mode 100644
index 000000000..f1b0a030e
--- /dev/null
+++ b/doc/app/app_cookbook/index-writers.rst
@@ -0,0 +1,14 @@
+Writing Data 
+============
+
+.. _writers:
+Apps with the type ``"writer"`` will write data to disk. Most cogent3 apps that are writers require a data store to write to.
+
+.. toctree::
+    :maxdepth: 1
+
+    write-seqs
+    write-tabular
+    write-json
+    write-db
+
diff --git a/doc/app/app_cookbook/load-aligned.rst b/doc/app/app_cookbook/load-aligned.rst
new file mode 100644
index 000000000..c2243fa2a
--- /dev/null
+++ b/doc/app/app_cookbook/load-aligned.rst
@@ -0,0 +1,97 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Loading aligned sequence data
+-----------------------------
+
+We can load aligned sequence data using the ``load_aligned`` app. When making the app, you can optionally provide arguments for the molecular type of the sequence and the format of the data. 
+
+Loading aligned DNA sequences from a single fasta file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Here we load the brca1 gene in bats, providing the molecular type (``moltype="dna"``) and file format (``format="fasta"``). 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app
+
+    load_aligned_app = get_app("load_aligned", moltype="dna", format="fasta")
+    aln = load_aligned_app("data/brca1-bats.fasta")
+    aln
+
+Loading aligned protein sequences from a single phylip file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Here we load a globin alignment, providing the molecular type (``moltype="protein"``) and file format (``format="phylip"``). 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app
+
+    load_aligned_app = get_app("load_aligned", moltype="protein", format="phylip")
+    aln = load_aligned_app("data/abglobin_aa.phylip")
+    aln
+
+Loading aligned DNA sequences from multiple fasta files
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+In the above examples, the result is a single alignment, which could have been achieved using standard ``cogent3`` (``load_aligned_seqs()``). The real power of apps is for batch processing of a large number of files.
+
+To apply apps to multiple files we need to set two things up:
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+1. A data store that identifies the files we are interested in 
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+Here, we create a data store containing all the files with the ".fasta" suffix in the data directory, limiting the data store to two members as a minimum example.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import open_data_store
+
+    fasta_seq_dstore = open_data_store("data", suffix="fasta", mode="r", limit=2)
+
+2. A composed process that defines our workflow 
+"""""""""""""""""""""""""""""""""""""""""""""""
+
+In this example, our process loads the sequences, filters the sequences to keep only the third codon position, and then writes the filtered sequences to a data store. 
+
+.. note:: Apps that are "writers" require a data store to write to, learn more about writers :ref:`here! <writers>` 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app, open_data_store
+
+    out_dstore = open_data_store(path_to_dir, suffix="fa", mode="w")
+
+    loader = get_app("load_aligned", format="fasta", moltype="dna")
+    cpos3 = get_app("take_codon_positions", 3)
+    writer = get_app("write_seqs", out_dstore, format="fasta")
+
+    process = loader + cpos3 + writer
+
+.. tip:: When running this code on your machine, remember to replace ``path_to_dir`` with an actual directory path.
+
+Now we're good to go, we can apply ``process`` to our data store!
+"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+``result`` is a data store, which you can index to see individual data members - which are our alignments. We can take a closer look using the ``.read()`` method on data members (truncating to 50 characters). 
+
+.. jupyter-execute::
+    :raises:
+
+    result = process.apply_to(fasta_seq_dstore)
+    print(result[0].read()[:50])
diff --git a/doc/app/app_cookbook/load-json.rst b/doc/app/app_cookbook/load-json.rst
new file mode 100644
index 000000000..f96f78aa1
--- /dev/null
+++ b/doc/app/app_cookbook/load-json.rst
@@ -0,0 +1,46 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+.. _load_json:
+
+Loading JSON serialised objects
+-------------------------------
+
+The ``load_json`` app loads JSON serialised ``cogent3`` objects from a file, returning whatever was stored.  
+
+In the :ref:`writing JSON section <write_json>`, we wrote a likelihood function object to disk in JSON serialised format, now let's load it back! All we need is the path to which it was written. Since ``write_json`` writes to a data store, this will be ``<path_to_dstore>/<identifier>``. 
+
+.. tip::  To follow along with this example, first head to the :ref:`writing JSON section <write_json>` to write out this data, or replace the path with a JSON file on your machine. 
+
+.. jupyter-execute::
+    :hide-code:
+    
+    from cogent3 import get_app, open_data_store
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+    # create lf object to write
+    load_aligned_app = get_app("load_aligned", moltype="dna", format="fasta")
+    aln = load_aligned_app("data/primate_brca1.fasta")
+    gn_model_app = get_app("model", "GN", tree="data/primate_brca1.tree")
+    lf = gn_model_app(aln).lf
+
+    out_dstore = open_data_store(path_to_dir, mode="w", suffix="json")
+
+    # write with the same identifier as the write_json example
+    write_json_app = get_app("write_json", data_store=out_dstore)
+    data_member = write_json_app(lf, identifier="gn_params.json")
+    path_to_dstore = data_member.data_store.source
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    load_json_app = get_app("load_json")
+    lf = load_json_app(f"{path_to_dstore}/gn_params.json")
+    lf
diff --git a/doc/app/app_cookbook/load-tabular.rst b/doc/app/app_cookbook/load-tabular.rst
new file mode 100644
index 000000000..618af728f
--- /dev/null
+++ b/doc/app/app_cookbook/load-tabular.rst
@@ -0,0 +1,49 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Loading tabular data
+--------------------
+
+How to load data from a tsv file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can load a file containing tab seperated values with the ``load_tabular`` app, providing it with the appropriate separator, ``sep="\t"``. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    load_tsv_app = get_app("load_tabular", sep="\t")
+    tsv_data = load_tsv_app("data/stats.tsv")
+    tsv_data
+
+How to load data from a csv file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can load a file containing comma seperated values with the ``load_tabular`` app, providing it with the seperator, ``sep=","``. 
+
+In the above example, ``data`` is a ``cogent3.util.Table`` object, if we write it to disk in csv format, we can re-use it for this example too. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import NamedTemporaryFile
+
+    tmpdir = NamedTemporaryFile(dir=".")
+    path_to_csv_file = tmpdir.name
+
+.. tip:: If you are executing this code on your machine, replace ``path_to_csv_file`` with the path containing your csv file!
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    tsv_data.write(path_to_csv_file, sep=",")
+
+    load_csv_app = get_app("load_tabular", sep=",")
+    csv_data = load_csv_app(path_to_csv_file)
+    csv_data
\ No newline at end of file
diff --git a/doc/app/app_cookbook/load-unaligned.rst b/doc/app/app_cookbook/load-unaligned.rst
new file mode 100644
index 000000000..d29d8a43d
--- /dev/null
+++ b/doc/app/app_cookbook/load-unaligned.rst
@@ -0,0 +1,77 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Loading unaligned sequence data
+-------------------------------
+
+We can load unaligned sequence data using the ``load_unaligned`` app, this will return a ``SequenceCollection``. 
+
+Loading unaligned DNA sequences from a single fasta file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+In this example, we load unaligned DNA sequences from a single fasta file using the ``load_unaligned`` app. We specify the molecular type ``(moltype="protein")`` and the file format ``(format="fasta")``.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    load_unaligned_app = get_app("load_unaligned", format="fasta", moltype="protein")
+    seqs = load_unaligned_app("data/inseqs_protein.fasta")
+    seqs
+
+Loading unaligned DNA sequences from multiple fasta files
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+To load unaligned DNA sequences from multiple fasta files, we need two things, a data store that identifies the files we are interested in and a process composed of our apps of interest. 
+
+1. A data store that identifies the files we are interested in 
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+Here we open a read-only (``mode="r"``) data store that identifies all fasta files in the data directory, limiting the data store to two members as a minimum example.
+
+.. jupyter-execute::
+    :hide-code:
+
+    
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    fasta_seq_dstore = open_data_store("data", suffix="fasta", mode="r", limit=2)
+
+2. A composed process that defines our workflow 
+"""""""""""""""""""""""""""""""""""""""""""""""
+
+In this example, our process loads the unaligned sequences using ``load_unaligned``, then applies ``jaccard_dist`` to estimate a kmer-based genetic distance, which we write out to a data store using ``write_tabular``. 
+
+.. note:: Apps that are "writers" require a data store to write to, learn more about writers :ref:`here! <writers>`. 
+
+.. jupyter-execute::
+    :raises:
+
+    out_dstore = open_data_store(path_to_dir, suffix="tsv", mode="w")
+
+    load_unaligned_app = get_app("load_unaligned", format="fasta", moltype="dna")
+    jdist = get_app("jaccard_dist")
+    writer = get_app("write_tabular", out_dstore, format="tsv")
+
+    process = load_unaligned_app + jdist + writer
+
+.. tip:: When running this code on your machine, remember to replace ``path_to_dir`` with an actual directory path.
+
+Now we're good to go! We can apply ``process`` to our data store of fasta sequences. ``result`` is a data store, which you can index to see individual data members. We can inspect a given data member using the ``.read()`` on data members. 
+
+.. jupyter-execute::
+    :raises:
+
+    result = process.apply_to(fasta_seq_dstore)
+    print(result[1].read())
\ No newline at end of file
diff --git a/doc/app/app_cookbook/sample-concat.rst b/doc/app/app_cookbook/sample-concat.rst
new file mode 100644
index 000000000..711766921
--- /dev/null
+++ b/doc/app/app_cookbook/sample-concat.rst
@@ -0,0 +1,67 @@
+Concatenating alignments
+------------------------
+
+The ``concat`` app provides a mechanism to concatenate alignments. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    concat_alns_app = get_app("concat", moltype="dna")
+
+Let's create sample alignments with matching sequence names to use in the below examples. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    aln1 = make_aligned_seqs({"s1": "AAA", "s2": "CAA", "s3": "AAA"}, moltype="dna")
+    aln2 = make_aligned_seqs({"s1": "GCG", "s2": "GGG", "s3": "GGT"}, moltype="dna")
+    aln1
+
+.. jupyter-execute::
+    :raises:
+
+    aln2
+
+How to concatenate alignments
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+By default, sequences without matching names in the corresponding alignment are omitted (``intersect=True``).
+
+.. jupyter-execute::
+    :raises:
+
+    result = concat_alns_app([aln1, aln2])
+    result
+
+How to concatenate alignments with missing sequences
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+By providing the argument ``intersect=False``, the ``concat`` app will include missing sequences across alignments. Missing sequences are replaced by a sequence of ``"?"``.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs, get_app
+
+    concat_missing = get_app("concat", moltype="dna", intersect=False)
+    aln3 = make_aligned_seqs({"s4": "GCG", "s5": "GGG"}, moltype="dna")
+    result = concat_missing([aln1, aln3])
+    result
+
+How to concatenated alignments with a delimiter ``"N"``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+You can insert an ``"N"`` character in between the concatenated sequences. 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app
+
+    concat_delim = get_app("concat", join_seq="N", moltype="dna")
+    result = concat_delim([aln1, aln2])
+    result
diff --git a/doc/app/app_cookbook/sample-fixed-len.rst b/doc/app/app_cookbook/sample-fixed-len.rst
new file mode 100644
index 000000000..9f871e986
--- /dev/null
+++ b/doc/app/app_cookbook/sample-fixed-len.rst
@@ -0,0 +1,55 @@
+Sample an alignment to a fixed length
+-------------------------------------
+
+Let's load in an alignment of rodents to use in the examples. 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app
+
+    loader = get_app("load_aligned", moltype="protein", format="phylip")
+    aln = loader("data/abglobin_aa.phylip")
+    aln
+
+How to sample the first ``n`` positions of an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can use the ``fixed_length`` app to sample an alignment to a fixed length. By default, it will sample from the beginning of an alignment, the argument ``length=20`` specifies how many positions to sample. 
+
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    first_20 = get_app("fixed_length", length=20)
+    first_20(aln)
+
+
+How to sample ``n`` positions from within an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Creating the ``fixed_length`` app with the argument ``start=x`` specifies that the sampled sequence should begin ``x`` positions into the alignment. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    skip_10_take_20 = get_app("fixed_length", length=20, start=10)
+    skip_10_take_20(aln)
+
+
+How to sample ``n`` positions randomly from within an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The start position can be selected at random with ``random=True``. An optional ``seed`` can be provided to ensure the same start position is used when the app is called.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    random_20 = get_app("fixed_length", length=20, random=True)
+    random_20(aln)
diff --git a/doc/app/app_cookbook/sample-min-len.rst b/doc/app/app_cookbook/sample-min-len.rst
new file mode 100644
index 000000000..3a7ccd955
--- /dev/null
+++ b/doc/app/app_cookbook/sample-min-len.rst
@@ -0,0 +1,65 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Filter sequence collections and alignments by length
+----------------------------------------------------
+
+Let's load a collection of globin sequences. Note that we must have a the molecular type specified. 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app
+
+    loader = get_app("load_unaligned", moltype="dna", format="fasta")
+    aln = loader("data/SCA1-cds.fasta")
+    aln
+
+Remove sequences shorter than a minimum length
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Creating the ``min_length`` app and providing a positional argument specifying the minimum length allows us to filter an alignment, removing sequences which do not satisfy our threshold. 
+
+For instance, we can remove sequences from the globin alignment which are shorter than 240 amino acids long. 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app
+
+    over_240s = get_app("min_length", 240)
+    aln_fltrd = over_240s(aln)
+    aln_fltrd
+
+Using the ``min_length`` app to filter multiple alignments
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+``min_length`` is a useful app to use in data processing pipelines where downstream analysis requires sequences that exceed a given length.
+
+In the following example, we compose a process that loads alignments and removes aligments less than 300 nucleotides in length, before writing them to a data store. We apply this process to a data store of the fasta files in the data directory. We restrict this data store to two members as a minimum example. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    dstore = open_data_store("data", suffix="fasta", limit=2)
+
+    reader = get_app("load_aligned", format="fasta", moltype="dna")
+    min_length = get_app("min_length", 300)
+    out_dstore = open_data_store(path_to_dir, mode="w", suffix="fasta")
+    writer = get_app("write_seqs", out_dstore)
+
+    process = reader + min_length + writer
+    result = process.apply_to(dstore)
+    result.describe
diff --git a/doc/app/app_cookbook/sample-omit-bad.rst b/doc/app/app_cookbook/sample-omit-bad.rst
new file mode 100644
index 000000000..a180f61d5
--- /dev/null
+++ b/doc/app/app_cookbook/sample-omit-bad.rst
@@ -0,0 +1,50 @@
+Remove problem sequences from an alignment
+------------------------------------------
+
+Using ``omit_bad_seqs`` we can eliminate sequences from an ``Alignment`` based on their gap fraction and/or the number of gaps they uniquely introduce. 
+
+Let's create a sample alignment with some gaps. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    aln = make_aligned_seqs(
+        {
+            "s1": "---ACC---TT-",
+            "s2": "---ACC---TT-",
+            "s3": "---ACC---TT-",
+            "s4": "--AACCG-GTT-",
+            "s5": "--AACCGGGTTT",
+            "s6": "AGAACCGGGTT-",
+            "s7": "------------",
+        },
+        moltype="dna",
+    )
+
+Removing sequences with more than X% gaps
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Creating the ``omit_bad_seqs`` app with the argument ``gap_fraction=0.5`` will omit sequences that contain 50% or more gaps.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_frac_05 = get_app("omit_bad_seqs", gap_fraction=0.5)
+    omit_frac_05(aln)
+
+Removing sequences that contribute many gaps
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The ``quantile=0.8`` argument omits sequences that are ranked above the specified quantile with respect to the number of gaps uniquely introduced into the alignment. In the following example, sequence ``s6`` is omitted, as it uniquely introduces gaps in the first two positions of the alignment.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_quant_08 = get_app("omit_bad_seqs", quantile=0.8)
+    omit_quant_08(aln)
diff --git a/doc/app/app_cookbook/sample-omit-degen.rst b/doc/app/app_cookbook/sample-omit-degen.rst
new file mode 100644
index 000000000..e62bd7b4a
--- /dev/null
+++ b/doc/app/app_cookbook/sample-omit-degen.rst
@@ -0,0 +1,58 @@
+Removing degenerate characters
+------------------------------
+
+Degenerate IUPAC base symbols represent a site position that can have multiple possible characters. For a DNA example, "Y" represents pyrimidines where the site can be either "C" or "T".
+
+.. note:: In many molecular evolutionary and phylogenetic analyses, the gap character "-" is treated "N", meaning any base.
+
+Let's create sample data with degenerate characters
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    aln = make_aligned_seqs({"s1": "ACGA-GACG", "s2": "GATGATGYT"}, moltype="dna")
+    aln
+
+Omit aligned columns containing a degenerate character
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_degens = get_app("omit_degenerates", moltype="dna")
+    result = omit_degens(aln)
+    result
+
+
+Omit all degenerate characters except gaps from an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+If we create the app with the argument ``gap_is_degen=False``, we can omit degenerate characters but retain gaps. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_degens_keep_gaps = get_app("omit_degenerates", moltype="dna", gap_is_degen=False)
+    result = omit_degens_keep_gaps(aln)
+    result
+
+Omit k-mers which contain degenerate characters
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+If we create ``omit_degenerates`` with the argument ``motif_length``, it will split sequences into non-overlapping tuples of the specified length and exclude any tuple that contains a degenerate character. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_degenerates_app = get_app("omit_degenerates", moltype="dna", motif_length=2)
+    result = omit_degenerates_app(aln)
+
+    result
diff --git a/doc/app/app_cookbook/sample-omit-duplicated.rst b/doc/app/app_cookbook/sample-omit-duplicated.rst
new file mode 100644
index 000000000..18904852c
--- /dev/null
+++ b/doc/app/app_cookbook/sample-omit-duplicated.rst
@@ -0,0 +1,69 @@
+Remove duplicated sequences from an alignment
+---------------------------------------------
+
+The ``omit_duplicated`` app removes redundant sequences from a sequence collection (aligned or unaligned).
+
+Let's create sample data with duplicated sequences.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    data = {
+        "a": "ACGT",
+        "b": "ACG-",  # identical to 'a' except has a gap
+        "c": "ACGG",  # duplicate
+        "d": "ACGG",  # duplicate
+        "e": "AGTC",  # unique
+    }
+    aln = make_aligned_seqs(data, moltype="dna")
+    aln
+
+Creating the ``omit_duplicated`` app with the argument ``choose="longest"`` selects the duplicated sequence with the least number of gaps and ambiguous characters. In the above example, only one of ``c`` and ``d`` will be retained.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_shorter_duplicate = get_app("omit_duplicated", moltype="dna", choose="longest")
+    omit_shorter_duplicate(aln)
+
+Creating the ``omit_duplicated`` app with the argument  ``choose=None`` means only unique sequences are retained.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_all_duplicates = get_app("omit_duplicated", moltype="dna", choose=None)
+    omit_all_duplicates(aln)
+
+The ``mask_degen`` argument specifies how to treat matches between sequences with degenerate characters. 
+
+Let's create sample data that has a DNA ambiguity code.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    aln = make_aligned_seqs(
+        {
+            "s1": "ATCG",
+            "s2": "ATYG",  # matches s1 with ambiguity
+            "s3": "GGTA",
+        },
+        moltype="dna",
+    )
+
+Since "Y" represents pyrimidines where the site can be either "C" or "T", s1 indeed matches s2 and one of them will be removed. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    app_dna = get_app("omit_duplicated", mask_degen=True, choose="longest", moltype="dna")
+    app_dna(aln)
\ No newline at end of file
diff --git a/doc/app/app_cookbook/sample-omit-gap.rst b/doc/app/app_cookbook/sample-omit-gap.rst
new file mode 100644
index 000000000..b9303590a
--- /dev/null
+++ b/doc/app/app_cookbook/sample-omit-gap.rst
@@ -0,0 +1,52 @@
+
+Removing highly gapped positions
+--------------------------------
+
+Using the ``omit_gap_pos`` app, we can remove position from an alignment which exceed a specified proportions of gaps. 
+
+Let's create a sample alignment with gaps. 
+        
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    aln = make_aligned_seqs({"s1": "ACGA-GA-CG", "s2": "GATGATG-AT"}, moltype="dna")
+    aln
+
+Removing highly gapped nucleotide positions
+"""""""""""""""""""""""""""""""""""""""""""
+
+Sites with over 99% gaps are excluded by default.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    omit_gap_pos_app = get_app("omit_gap_pos", moltype="dna")
+    result = omit_gap_pos_app(aln)
+    result
+
+We can alter the threshold for the allowed fraction of gaps with the ``allowed_frac`` argument. Let's create an app that excludes all aligned sites with over 49% gaps.
+
+.. jupyter-execute::
+    :raises:
+
+    omit_gap_pos_app = get_app("omit_gap_pos", allowed_frac=0.49, moltype="dna")
+    result = omit_gap_pos_app(aln)
+    result
+
+Removing highly gapped codon positions
+""""""""""""""""""""""""""""""""""""""
+
+To eliminate any codon columns (where a column is a triple of nucleotides) that contain a gap character, we use the ``motif_length`` argument.
+
+.. jupyter-execute::
+    :raises:
+
+    omit_gap_pos_app = get_app(
+        "omit_gap_pos", allowed_frac=0, motif_length=3, moltype="dna"
+    )
+    result = omit_gap_pos_app(aln)
+    result
\ No newline at end of file
diff --git a/doc/app/app_cookbook/sample-take-n-seqs.rst b/doc/app/app_cookbook/sample-take-n-seqs.rst
new file mode 100644
index 000000000..8a9458236
--- /dev/null
+++ b/doc/app/app_cookbook/sample-take-n-seqs.rst
@@ -0,0 +1,75 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Select `n` sequences from a collection
+--------------------------------------
+
+Let's load an alignment of primates to use in examples. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    loader = get_app("load_aligned", moltype="dna")
+    aln = loader("data/primate_brca1.fasta")
+    aln
+
+Select the first `n` sequences from an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Initialising ``take_n_seqs`` with the argument ``number=3`` creates an app that returns the first 3 sequences from an alignment 
+
+.. note::  "first n" refers to the ordering in the fasta file. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    first_3 = get_app("take_n_seqs", number=3)
+    first_3(aln)
+
+Randomly selecting `n` sequences from an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Using ``random=True`` and ``number=3`` returns 3 random sequences. An optional argument for a ``seed`` can be provided to ensure the same sequences are returned each time the app is called.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    random_n = get_app("take_n_seqs", random=True, number=3, seed=1)
+    random_n(aln)
+
+Selecting the same sequences from multiple alignments
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Providing the argument ``fixed_choice=True`` ensures the same sequences are returned when (randomly) sampling sequences across several alignments.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    loader = get_app("load_aligned", moltype="dna")
+    aln1 = loader("data/primate_brca1.fasta")
+    aln2 = loader("data/brca1.fasta")
+
+    aln1.names
+
+.. jupyter-execute::
+    :raises:
+
+    aln2.names
+
+.. jupyter-execute::
+    :raises:
+
+    fixed_choice = get_app("take_n_seqs", number=2, random=True, fixed_choice=True)
+    result1 = fixed_choice(aln1).names
+    result2 = fixed_choice(aln2).names
+    result1 == result2
\ No newline at end of file
diff --git a/doc/app/app_cookbook/sample-take-pos.rst b/doc/app/app_cookbook/sample-take-pos.rst
new file mode 100644
index 000000000..4b7ae8ec1
--- /dev/null
+++ b/doc/app/app_cookbook/sample-take-pos.rst
@@ -0,0 +1,109 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Sample nucleotides from a given codon position
+----------------------------------------------
+
+The ``take_codon_positions`` app allows you to extract all nucleotides at a given codon position from an alignment. 
+
+Let's create a sample alignment for our example. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import make_aligned_seqs
+
+    aln = make_aligned_seqs({"s1": "ACGACGACG", "s2": "GATGATGAT"}, moltype="dna")
+    aln
+
+Extract the third codon position from an alignment 
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can achieve this by creating the ``take_codon_positions`` app with ``3`` as a positional argument.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    take_pos3 = get_app("take_codon_positions", 3, moltype="dna")
+    result = take_pos3(aln)
+    result
+
+Extract the first and second codon positions from an alignment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can achieve this by creating the ``take_codon_positions`` app with ``1`` and ``2`` as a positional argument. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    take_pos12 = get_app("take_codon_positions", 1, 2, moltype="dna")
+    result = take_pos12(aln)
+    result
+
+Extract only the third codon positions from four-fold degenerate codons
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can achieve this by creating the ``take_codon_positions`` app with the argument ``fourfold_degenerate=True``. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, make_aligned_seqs
+
+    aln_ff = make_aligned_seqs({"s1": "GCAAGCGTTTAT", "s2": "GCTTTTGTCAAT"})
+    take_fourfold = get_app("take_codon_positions", fourfold_degenerate=True, moltype="dna")
+    result = take_fourfold(aln_ff)
+    result
+
+Create a composed process which samples only the third codon position
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+Let's set up a data store containing all the files with the ".fasta" suffix in the data directory, limiting the data store to two members as a minimum example.
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import open_data_store
+
+    fasta_seq_dstore = open_data_store("data", suffix="fasta", mode="r", limit=2)
+
+Now let's set up a process composing the following apps: ``load_aligned`` (loads the sequences ), ``take_codon_positions`` (extracts the third codon position), and ``write_seqs`` (writes the filtered sequences to a data store). 
+
+.. note:: Learn the basics of turning apps into composed processes :ref:`here! <apps>` 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app, open_data_store
+
+    out_dstore = open_data_store(path_to_dir, suffix="fa", mode="w")
+
+    loader = get_app("load_aligned", format="fasta", moltype="dna")
+    cpos3 = get_app("take_codon_positions", 3)
+    writer = get_app("write_seqs", out_dstore, format="fasta")
+
+    process = loader + cpos3 + writer
+
+.. tip:: When running this code on your machine, remember to replace ``path_to_dir`` with an actual directory path.
+
+Now let's apply ``process`` to our data store! This populates ``out_dstore`` (which is returned by the ``.apply_to()`` call) with the filtered alignments. We can index ``out_dstore`` to see individual data members. We could take a closer look using the ``.read()`` method on data members. 
+
+.. jupyter-execute::
+    :raises:
+
+    out_dstore = process.apply_to(fasta_seq_dstore)
+    out_dstore.describe
diff --git a/doc/app/app_cookbook/sample-take-seqs.rst b/doc/app/app_cookbook/sample-take-seqs.rst
new file mode 100644
index 000000000..696c7544b
--- /dev/null
+++ b/doc/app/app_cookbook/sample-take-seqs.rst
@@ -0,0 +1,59 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Select named sequences from a collection
+----------------------------------------
+
+Let's load alignment of primates to use in examples. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    loader = get_app("load_aligned", moltype="dna")
+    aln = loader("data/primate_brca1.fasta")
+    aln
+
+Selecting sequences from a collection
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Using the ``take_named_seqs`` app we can select sequences from a collection that match provided names. For instance, we could be interested in an alignment of sequences from Humans with those from our two closest relatives. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    greater_ape_selector = get_app("take_named_seqs", "Human", "Chimpanzee", "Gorilla")
+    ape_aln = greater_ape_selector(aln)
+    ape_aln
+
+Using ``take_named_seqs`` in a composed app process
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. jupyter-execute::
+
+    from cogent3 import get_app
+
+    loader = get_app("load_aligned", format="fasta", moltype="dna")
+    select_seqs = get_app("take_named_seqs", "Human", "Rhesus", "Galago")
+    process = loader + select_seqs
+    hrg_aln = process("data/primate_brca1.fasta")
+    hrg_aln.names
+
+Removing sequences from a collection
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Creating the ``take_named_seqs`` app with the argument ``negate=True`` will exclude sequences that match the provided names. For instance, perhaps the Galago has got to go. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    no_galagos = get_app("take_named_seqs", "Galago", negate=True)
+    no_galago_aln = no_galagos(hrg_aln)
+    no_galago_aln.names
diff --git a/doc/app/app_cookbook/sample-trim-stops.rst b/doc/app/app_cookbook/sample-trim-stops.rst
new file mode 100644
index 000000000..741ab9e47
--- /dev/null
+++ b/doc/app/app_cookbook/sample-trim-stops.rst
@@ -0,0 +1,16 @@
+Remove trailing stop codons from sequences in an alignment
+----------------------------------------------------------
+
+For evolutionary analyses that use codon models, we need to exclude terminating stop codons. To account for the difference in stop codons in different genetic codes, we can provide an argument for the genetic code. For a list of all genetic codes, see :ref:`here <genetic-codes>`. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, make_aligned_seqs
+
+    aln = make_aligned_seqs(
+        data={"seq1": "ACGTAA---", "seq2": "ACGACA---", "seq3": "ACGCAATGA"},
+        moltype="dna",
+    )
+    no_stops = get_app("trim_stop_codons", gc=1)
+    no_stops(aln)
\ No newline at end of file
diff --git a/doc/app/app_cookbook/write-db.rst b/doc/app/app_cookbook/write-db.rst
new file mode 100644
index 000000000..982b0089c
--- /dev/null
+++ b/doc/app/app_cookbook/write-db.rst
@@ -0,0 +1,42 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+.. _write_db:
+
+Writing a database to file
+--------------------------
+
+``write_db`` can be used to write serialised objects to a database instance. In the below example, we use it to write the output of a composed process to disk. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    dstore = open_data_store("data", suffix="fasta", limit=2)
+
+    reader = get_app("load_aligned", format="fasta", moltype="dna")
+    min_length = get_app("min_length", 300)
+    out_dstore = open_data_store(f"{path_to_dir}.sqlitedb", mode="w")
+    writer = get_app("write_db", out_dstore)
+
+    process = reader + min_length + writer
+    result = process.apply_to(dstore)
+    result.describe
+
+Note that ``out_dstore`` and ``result`` are the same instance.
+
+.. jupyter-execute::
+    :raises:
+    
+    out_dstore is result
diff --git a/doc/app/app_cookbook/write-json.rst b/doc/app/app_cookbook/write-json.rst
new file mode 100644
index 000000000..9e31c6dbe
--- /dev/null
+++ b/doc/app/app_cookbook/write-json.rst
@@ -0,0 +1,60 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+.. _write_json:
+
+Writing JSON Serialised Objects
+-------------------------------
+
+Using JSON, we can serialise ``cogent3`` objects to a file for easy storage and retrieval.
+
+Create an example object to serialise
+"""""""""""""""""""""""""""""""""""""
+
+Let's create a ``LikelihoodFunction`` object to use in this example. It is generated from fitting the General Nucleotide (GN) model to an alignment of BRCA1 in primates. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    # Load the alignment
+    load_aligned_app = get_app("load_aligned", moltype="dna", format="fasta")
+    aln = load_aligned_app("data/primate_brca1.fasta")
+
+    # Fit the GN model
+    gn_model_app = get_app("model", "GN", tree="data/primate_brca1.tree")
+    model_result = gn_model_app(aln)
+    model_result.lf
+
+``write_json`` -  writing JSON-serialised object to file 
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+Using the ``write_json`` app, we can write out the likelihood function as a JSON-serialised object, making it easy to retrieve the model parameters for future reference if required. 
+
+We need to provide the ``write_json`` app with a data store to which it will write. Optionally when we apply the app we can specify an identifier for the data, which will name the file. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    # Initialise the write_json app with a data store
+    out_dstore = open_data_store(path_to_dir, mode="w", suffix="json")
+    write_json_app = get_app("write_json", data_store=out_dstore)
+
+    write_json_app(model_result.lf, identifier="gn_params.json")
+
+.. note:: Learn how to load a JSON serialised object in the :ref:`loading JSON serialised objects <load_json>` section!
+
+.. tip:: When running this code on your machine, remember to replace ``path_to_dir`` with an actual directory path.
diff --git a/doc/app/app_cookbook/write-seqs.rst b/doc/app/app_cookbook/write-seqs.rst
new file mode 100644
index 000000000..95833eefc
--- /dev/null
+++ b/doc/app/app_cookbook/write-seqs.rst
@@ -0,0 +1,111 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Writing sequences and sequence alignments
+-----------------------------------------
+
+Writing a sequence alignment to disk
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Writing out an alignment can be achieved easily with the ``write_seqs`` app. First, let's load the alignment we want to write. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    load_aligned_app = get_app("load_aligned", moltype="dna", format="fasta")
+    aln = load_aligned_app("data/primate_brca1.fasta")
+    aln
+
+When creating the ``write_seqs`` app, we need to provide a data store to which we want the data to be written, and optionally, we can specify the format we want the sequences to be written in. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    seq_dstore = open_data_store(path_to_dir, suffix="phylip", mode="w")
+
+    write_seqs_app = get_app("write_seqs", data_store=seq_dstore, format="phylip")
+    result = write_seqs_app(aln)
+
+    result.read()[:50]
+
+Writing many sequence collections to a data store
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Typically, the final step of a data processing pipeline is writing out the filtered data. When ``write_seqs`` is composed into a process, the process will write out multiple sequence collections to a data store. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+We can create our input data store containing all the files with the ".fasta" suffix in the data directory using ``open_data_store``. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import open_data_store
+
+    fasta_seq_dstore = open_data_store("data", suffix="fasta", mode="r")
+
+Let's define a process. In this example, our process loads the sequences, filters the sequences to keep only those which are translatable, translates the sequences, and then writes the filtered sequences to a data store. 
+
+.. jupyter-execute::
+    :raises:
+    
+    from cogent3 import get_app, open_data_store
+
+    out_dstore = open_data_store(path_to_dir, suffix="fa", mode="w")
+
+    loader = get_app("load_unaligned", format="fasta", moltype="dna")
+    keep_translatable = get_app("select_translatable")
+    translate = get_app("translate_seqs")
+    writer = get_app("write_seqs", out_dstore, format="fasta")
+
+    process = loader + keep_translatable + translate + writer
+
+.. tip:: When running this code on your machine, remember to replace ``path_to_dir`` with an actual directory path.
+
+We apply ``process`` to our input data store, and assign the resulting data store to ``result``. 
+
+.. jupyter-execute::
+    :raises:
+
+    result = process.apply_to(fasta_seq_dstore)
+
+Accessing an overview of our process
+""""""""""""""""""""""""""""""""""""
+
+We can interrogate ``result`` to see an overview of the process. 
+
+.. jupyter-execute::
+    :raises:
+
+    result.describe
+
+There were 10 data files to which the process was successfully applied. However, there were three files for which the process did not complete. We can see a summary of the failures by accessing the ``summary_not_completed`` property. 
+
+.. jupyter-execute::
+    :raises:
+
+    result.summary_not_completed
+
+Looks like the first two failed because they are protein sequences and ``load_unaligned`` expected DNA sequences. 
+
+Interestingly, another file failed in the ``keep_translatable`` step. By design, these failures did not stop the rest of the pipeline from being run. In fact, the data store collects the :ref:`NotCompleted objects <not_completed>`, which store traceback information, allowing you to interrogate any failings. 
diff --git a/doc/app/app_cookbook/write-tabular.rst b/doc/app/app_cookbook/write-tabular.rst
new file mode 100644
index 000000000..968a9c852
--- /dev/null
+++ b/doc/app/app_cookbook/write-tabular.rst
@@ -0,0 +1,113 @@
+.. jupyter-execute::
+    :hide-code:
+
+    import set_working_directory
+
+Writing tabular data
+--------------------
+
+With the ``write_tabular`` app, ``cogent3`` "TabularTypes" (``Table``, ``DictArray``, ``DistanceMatrix``) are supported for writing to disk. 
+
+Let's generate a ``cogent3`` ``Table`` to use in the examples below. One way to do that is by applying the ``tabulate_stats`` app to a model result. 
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app
+
+    # load alignment
+    load_aligned_app = get_app("load_aligned", moltype="dna")
+    aln = load_aligned_app("data/primate_brca1.fasta")
+
+    # fit GN model
+    gn_model_app = get_app("model", "GN", tree="data/primate_brca1.tree")
+    model_result = gn_model_app(aln)
+
+    # tabulate the model result
+    tabulator = get_app("tabulate_stats")
+    model_result_tab = tabulator(model_result)
+    motif_params = model_result_tab["motif params"]
+    print(type(motif_params))
+    motif_params
+
+Writing a CSV file
+^^^^^^^^^^^^^^^^^^
+
+To write in CSV format, we create the ``write_tabular`` app with ``format="csv"``. 
+
+.. jupyter-execute::
+    :hide-code:
+    
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    out_dstore = open_data_store(path_to_dir, mode="w", suffix="csv")
+
+    write_tabular_app = get_app("write_tabular", data_store=out_dstore, format="csv")
+    write_tabular_app(motif_params, identifier="gn_model_results.csv")
+
+
+Writing a TSV file
+^^^^^^^^^^^^^^^^^^
+
+To write in TSV format, we create the ``write_tabular`` app with ``format="tsv"``. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    out_dstore = open_data_store(path_to_dir, mode="w", suffix="tsv")
+
+    write_tabular_app = get_app("write_tabular", data_store=out_dstore, format="tsv")
+    write_tabular_app(motif_params, identifier="gn_model_results.tsv")
+
+Using ``write_tabular`` in a composed process
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Instead of applying the apps sequentially as above, we can add apps into a composed process, and apply the process to a data store. In this example, we define a process that calculates an unaligned distance measure between sequences, writing these estimated distances to a tsv file. 
+
+.. jupyter-execute::
+    :hide-code:
+
+    
+    from tempfile import TemporaryDirectory
+
+    tmpdir = TemporaryDirectory(dir=".")
+    path_to_dir = tmpdir.name
+
+.. jupyter-execute::
+    :raises:
+
+    from cogent3 import get_app, open_data_store
+
+    loader = get_app("load_unaligned", moltype="dna")
+    jdist = get_app("jaccard_dist")
+    out_dstore = open_data_store(path_to_dir, mode="w", suffix="tsv")
+    writer = get_app("write_tabular", data_store=out_dstore, format="tsv")
+
+    process = loader + jdist + writer
+
+    in_dstore = open_data_store("data", suffix="fasta", mode="r", limit=2)
+
+    result = process.apply_to(in_dstore)
+    result.describe
+
+.. tip:: When running this code on your machine, remember to replace ``path_to_dir`` with an actual directory path.
\ No newline at end of file
diff --git a/doc/app/app-get.rst b/doc/app/app_overview/app-get.rst
similarity index 100%
rename from doc/app/app-get.rst
rename to doc/app/app_overview/app-get.rst
diff --git a/doc/app/app-help.rst b/doc/app/app_overview/app-help.rst
similarity index 100%
rename from doc/app/app-help.rst
rename to doc/app/app_overview/app-help.rst
diff --git a/doc/app/app-overview.rst b/doc/app/app_overview/app-overview.rst
similarity index 74%
rename from doc/app/app-overview.rst
rename to doc/app/app_overview/app-overview.rst
index c07cfba39..22b680789 100644
--- a/doc/app/app-overview.rst
+++ b/doc/app/app_overview/app-overview.rst
@@ -8,13 +8,31 @@
 Overview of using apps
 ======================
 
-There are 3 top-level functions that provide the major means for identifying what apps are installed, what an app can do and for getting an app to use it. These functions are:
+What are apps?
+--------------
 
-- ``available_apps()`` (see :ref:`available_apps`)
-- ``app_help()`` (see :ref:`app_help`)
-- ``get_app()`` (see :ref:`get_app`)
+Apps are ready-made "functions" that you can apply to your data without needing to know all the technical details. They are easy to use, even if you're not an expert programmer. Multiple apps can be naturally composed into "pipelines", which are fully equipped for robust and reproducible application to data. In fact, apps and app pipelines can be applied to a single, or thousands, of data file(s) without writing loops or conditionals.
 
-Two other crucial concepts concern :ref:`data stores <data_stores>` and :ref:`tracking failures <not_completed>`.
+Apps have several key features, they:
+
+#. trap errors
+#. check the validity of input data
+#. can be used by themselves or combined into a "composed" app (aka pipeline)
+#. automatically track the relationship between an input data record and its output record
+
+Where to start?
+---------------
+
+Three top-level functions are very useful:
+
+- ``available_apps()`` identifies what apps are installed (see :ref:`available_apps`)
+- ``app_help()`` shows what a given app can do (see :ref:`app_help`)
+- ``get_app()`` returns an app instance for you to use (see :ref:`get_app`)
+
+Two other crucial concepts concern: 
+
+- :ref:`data stores <data_stores>` and 
+- :ref:`tracking failures <not_completed>`
 
 .. _app_types:
 
@@ -77,7 +95,7 @@ An example
 .. jupyter-execute::
     :hide-code:
 
-    from pathlib import Path
+    
     from tempfile import TemporaryDirectory
 
     tmpdir = TemporaryDirectory(dir=".")
@@ -95,8 +113,10 @@ I illustrate the general approach for a simple example -- extracting third codon
     cpos3 = get_app("take_codon_positions", 3)
     writer = get_app("write_seqs", out_dstore, format="fasta")
 
-Using apps sequentially like functions
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+There are two ways in which I can apply the three above apps to data:
+
+1. Using apps sequentially like functions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 .. jupyter-execute::
 
@@ -110,8 +130,8 @@ The resulting alignment ``just3rd`` will be written into the ``out_dstore`` dire
 
     ``m`` is a :ref:`DataMember <data_member>` of ``out_dstore``.
 
-Composing a multi-step process from several apps
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+2. Composing several apps into a multi-step "process"
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 We can make this simpler by creating a single composed function.
 
@@ -123,7 +143,7 @@ We can make this simpler by creating a single composed function.
 Applying a process to multiple data records
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-We use a data store to identify all data files in a directory that we want to analyse. ``process`` can be then applied to all records in the data store without having to loop.
+To apply a composed function to multiple files requires a :ref:`data store <data_stores>`. Using ``open_data_store()`` we identify all data files in a directory that we want to analyse, in the following case, all fasta file in the data directory. ``process`` can be then applied to all records in the data store without having to loop.
 
 .. jupyter-execute::
 
diff --git a/doc/app/available-apps.rst b/doc/app/app_overview/available-apps.rst
similarity index 100%
rename from doc/app/available-apps.rst
rename to doc/app/app_overview/available-apps.rst
diff --git a/doc/app/dstore.rst b/doc/app/app_overview/dstore.rst
similarity index 100%
rename from doc/app/dstore.rst
rename to doc/app/app_overview/dstore.rst
diff --git a/doc/app/not-completed.rst b/doc/app/app_overview/not-completed.rst
similarity index 100%
rename from doc/app/not-completed.rst
rename to doc/app/app_overview/not-completed.rst
diff --git a/doc/app/evo-ancestral-states.rst b/doc/app/app_tutorials/evo-ancestral-states.rst
similarity index 100%
rename from doc/app/evo-ancestral-states.rst
rename to doc/app/app_tutorials/evo-ancestral-states.rst
diff --git a/doc/app/evo-dt-nuc-model.rst b/doc/app/app_tutorials/evo-dt-nuc-model.rst
similarity index 100%
rename from doc/app/evo-dt-nuc-model.rst
rename to doc/app/app_tutorials/evo-dt-nuc-model.rst
diff --git a/doc/app/evo-extract-model-stats.rst b/doc/app/app_tutorials/evo-extract-model-stats.rst
similarity index 100%
rename from doc/app/evo-extract-model-stats.rst
rename to doc/app/app_tutorials/evo-extract-model-stats.rst
diff --git a/doc/app/evo-hypothesis.rst b/doc/app/app_tutorials/evo-hypothesis.rst
similarity index 100%
rename from doc/app/evo-hypothesis.rst
rename to doc/app/app_tutorials/evo-hypothesis.rst
diff --git a/doc/app/evo-model-timehet.rst b/doc/app/app_tutorials/evo-model-timehet.rst
similarity index 100%
rename from doc/app/evo-model-timehet.rst
rename to doc/app/app_tutorials/evo-model-timehet.rst
diff --git a/doc/app/evo-model-with-tree.rst b/doc/app/app_tutorials/evo-model-with-tree.rst
similarity index 100%
rename from doc/app/evo-model-with-tree.rst
rename to doc/app/app_tutorials/evo-model-with-tree.rst
diff --git a/doc/app/evo-model.rst b/doc/app/app_tutorials/evo-model.rst
similarity index 100%
rename from doc/app/evo-model.rst
rename to doc/app/app_tutorials/evo-model.rst
diff --git a/doc/app/evo-natsel_neutral.rst b/doc/app/app_tutorials/evo-natsel_neutral.rst
similarity index 100%
rename from doc/app/evo-natsel_neutral.rst
rename to doc/app/app_tutorials/evo-natsel_neutral.rst
diff --git a/doc/app/evo-natsel_sitehet.rst b/doc/app/app_tutorials/evo-natsel_sitehet.rst
similarity index 100%
rename from doc/app/evo-natsel_sitehet.rst
rename to doc/app/app_tutorials/evo-natsel_sitehet.rst
diff --git a/doc/app/evo-natsel_timehet.rst b/doc/app/app_tutorials/evo-natsel_timehet.rst
similarity index 100%
rename from doc/app/evo-natsel_timehet.rst
rename to doc/app/app_tutorials/evo-natsel_timehet.rst
diff --git a/doc/app/evo-natsel_zhang.rst b/doc/app/app_tutorials/evo-natsel_zhang.rst
similarity index 100%
rename from doc/app/evo-natsel_zhang.rst
rename to doc/app/app_tutorials/evo-natsel_zhang.rst
diff --git a/doc/app/evo-nstat-codon-model.rst b/doc/app/app_tutorials/evo-nstat-codon-model.rst
similarity index 100%
rename from doc/app/evo-nstat-codon-model.rst
rename to doc/app/app_tutorials/evo-nstat-codon-model.rst
diff --git a/doc/app/evo-tr-codon-model.rst b/doc/app/app_tutorials/evo-tr-codon-model.rst
similarity index 100%
rename from doc/app/evo-tr-codon-model.rst
rename to doc/app/app_tutorials/evo-tr-codon-model.rst
diff --git a/doc/app/evo-tr-nuc-model.rst b/doc/app/app_tutorials/evo-tr-nuc-model.rst
similarity index 100%
rename from doc/app/evo-tr-nuc-model.rst
rename to doc/app/app_tutorials/evo-tr-nuc-model.rst
diff --git a/doc/app/index-evo-hypothesis.rst b/doc/app/app_tutorials/index-evo-hypothesis.rst
similarity index 94%
rename from doc/app/index-evo-hypothesis.rst
rename to doc/app/app_tutorials/index-evo-hypothesis.rst
index 7c43de65d..061b4519d 100644
--- a/doc/app/index-evo-hypothesis.rst
+++ b/doc/app/app_tutorials/index-evo-hypothesis.rst
@@ -5,7 +5,6 @@ Takes two (or more) ``model`` instances and performs hypothesis test.
 
 .. toctree::
     :maxdepth: 1
-    :hidden:
 
     evo-hypothesis
 
diff --git a/doc/app/index-evo-model.rst b/doc/app/app_tutorials/index-evo-model.rst
similarity index 88%
rename from doc/app/index-evo-model.rst
rename to doc/app/app_tutorials/index-evo-model.rst
index 830129272..4cf858dcb 100644
--- a/doc/app/index-evo-model.rst
+++ b/doc/app/app_tutorials/index-evo-model.rst
@@ -3,11 +3,10 @@ Modelling sequence evolution
 
 ``cogent3`` possesses unique capabilities for molecular evolutionary analyses. Of particular significance are our non-stationary model classes and the associated measurement of genetic distance from these. That said, we also implement most of the conventional substitution models. See ``cogent3.available_models()`` for a display of the built-in models.
 
-You can use these models either through the general ``model`` app, or some task specific apps. Both are described below.
+You can use these models either through the general ``model`` app, or some task specific apps. Both are described in the following "how-tos".
 
 .. toctree::
-    :maxdepth: 2
-    :hidden:
+    :maxdepth: 1
 
     evo-model
     evo-model-with-tree
diff --git a/doc/app/index-evo-natsel.rst b/doc/app/app_tutorials/index-evo-natsel.rst
similarity index 100%
rename from doc/app/index-evo-natsel.rst
rename to doc/app/app_tutorials/index-evo-natsel.rst
diff --git a/doc/app/index-app.rst b/doc/app/index-app.rst
index 6978ae2f1..e853f2f9c 100644
--- a/doc/app/index-app.rst
+++ b/doc/app/index-app.rst
@@ -1,15 +1,14 @@
 Overview of apps
 ================
 
-Select your topic from the left sidebar.
+The fundamentals of using apps.
 
 .. toctree::
     :maxdepth: 2
-    :hidden:
 
-    app-overview
-    app-help
-    app-get
-    available-apps
-    dstore
-    not-completed
+    app_overview/app-overview
+    app_overview/app-help
+    app_overview/app-get
+    app_overview/available-apps
+    app_overview/dstore
+    app_overview/not-completed
diff --git a/doc/app/index-cookbook.rst b/doc/app/index-cookbook.rst
new file mode 100644
index 000000000..af79a7d5f
--- /dev/null
+++ b/doc/app/index-cookbook.rst
@@ -0,0 +1,12 @@
+App Cookbook
+============
+
+*Small code snippets on how-to use apps to solve individual problems*
+
+.. toctree::
+    :maxdepth: 1
+
+    app_cookbook/index-loaders
+    app_cookbook/index-align
+    app_cookbook/index-sample
+    app_cookbook/index-writers
diff --git a/doc/app/index-custom.rst b/doc/app/index-custom.rst
index b16b5be09..6adf91135 100644
--- a/doc/app/index-custom.rst
+++ b/doc/app/index-custom.rst
@@ -1,5 +1,5 @@
-Write your own Apps
-===================
+Custom Apps
+===========
 
 You can write your own apps.
 
diff --git a/doc/app/index-tutorials.rst b/doc/app/index-tutorials.rst
new file mode 100644
index 000000000..b47bb2cfa
--- /dev/null
+++ b/doc/app/index-tutorials.rst
@@ -0,0 +1,11 @@
+Tutorials
+=========
+Using apps to solve more extensive problems. Select your tutorial from the menu on the left.
+
+.. toctree::
+    :maxdepth: 1
+    :hidden:
+    
+    app_tutorials/index-evo-model
+    app_tutorials/index-evo-hypothesis
+    app_tutorials/index-evo-natsel
\ No newline at end of file
diff --git a/doc/app/index.rst b/doc/app/index.rst
index 345d41bbe..39f07ac83 100644
--- a/doc/app/index.rst
+++ b/doc/app/index.rst
@@ -2,24 +2,56 @@
 apps
 ####
 
-Apps are designed to enable usage without requiring detailed understanding of programming and so they can be applied in batch to a large amount of data. In other words, being able to use them as functions without writing loops or conditionals.
+Pre-defined "apps" simplify complex tasks. They provide capabilities that can be used by themselves, or added together to define a pipeline. Find out more about apps below!
 
-Apps can be used by themselves, or added together to define a "composed function" (aka pipeline). Composed functions can be applied to a single, or thousands, of data file(s).
+.. grid::
 
-Apps have several key features
+    .. grid-item-card::
+        :link: index-app.html
+        :link-type: url
+        :class-header: bg-info
 
-#. trap errors
-#. check the validity of input data
-#. can be used by themselves or combined into a "composed" app
-#. automatically track the relationship between an input data record and its output record
+        App Overview
+        ^^^^^^^^^^^^
 
+        An overview of the basic usage of apps. 
+        
+    .. grid-item-card::
+        :link: index-cookbook.html
+        :link-type: url
+        :class-header: bg-info
+
+        App Cookbook
+        ^^^^^^^^^^^^
+
+        Short "how-to" code recipes solving specific problems using apps.
+
+.. grid::
+
+    .. grid-item-card::
+        :link: index-tutorials.html
+        :link-type: url
+        :class-header: bg-info
+
+        App Tutorials
+        ^^^^^^^^^^^^^
+
+        Using apps to solve more extensive problems.
+
+    .. grid-item-card::
+        :link: index-custom.html
+        :link-type: url
+        :class-header: bg-info
+
+        Custom Apps
+        ^^^^^^^^^^^
+        Writing and publishing you own apps.
+        
 .. toctree::
     :maxdepth: 2
     :hidden:
     
     index-app
-    index-align
-    index-evo-model
-    index-evo-hypothesis
-    index-evo-natsel
+    index-cookbook
+    index-tutorials
     index-custom
diff --git a/doc/app/user_function.rst b/doc/app/user_function.rst
index cf0eb098b..0a9111c14 100644
--- a/doc/app/user_function.rst
+++ b/doc/app/user_function.rst
@@ -6,14 +6,14 @@ Just use the ``define_app`` decorator! This generates a wrapper class that has a
 You need two things:
 
 1. your function
-2. Type hints on the function first argument and the function return type.
+2. Type hints on the function's first argument and the function return type.
 
 .. note:: At present, your function can have only one required argument.
 
 Supported cogent3 types
 -----------------------
 
-If you function takes or returns cogent3 types, you can use the existing type hints. To see what these are, use the ``defined_types()`` function.
+If your function takes or returns ``cogent3`` types, you can use the existing type hints. To see what these are, use the ``defined_types()`` function.
 
 .. jupyter-execute::
 
diff --git a/doc/cookbook/genetic_code.rst b/doc/cookbook/genetic_code.rst
index 890da51ac..121dad53d 100644
--- a/doc/cookbook/genetic_code.rst
+++ b/doc/cookbook/genetic_code.rst
@@ -1,3 +1,5 @@
+.. _genetic-codes:
+
 Using genetic codes
 ^^^^^^^^^^^^^^^^^^^
 
diff --git a/doc/cookbook/manipulating_biological_data.rst b/doc/cookbook/manipulating_biological_data.rst
index 1b19c2dd4..f8d26d9bb 100644
--- a/doc/cookbook/manipulating_biological_data.rst
+++ b/doc/cookbook/manipulating_biological_data.rst
@@ -3,11 +3,11 @@ Manipulating biological data
 
 .. authors, Gavin Huttley, Kristian Rother, Patrick Yannul
 
-Sequences and Alignments
-------------------------
+Select your topic from the left sidebar.
 
 .. toctree::
     :maxdepth: 2
+    :hidden:
 
     loading_sequences
     DNA_and_RNA_sequences
@@ -18,20 +18,16 @@ Sequences and Alignments
     moltypes
     alphabet
 
-Genetic code
-------------
-
 .. toctree::
     :maxdepth: 1
+    :hidden:
 
     what_codes
     genetic_code
 
-Trees
------
-
 .. toctree::
     :maxdepth: 1
+    :hidden:
 
     simple_trees
 
diff --git a/doc/set_working_directory.py b/doc/set_working_directory.py
index b3a156f2a..5a157d7f3 100644
--- a/doc/set_working_directory.py
+++ b/doc/set_working_directory.py
@@ -1,6 +1,7 @@
 """sets as working directory the parent of data/
 
 Assumes this file is distributed inside COGENT3_ROOT/doc directory"""
+
 import os
 import pathlib
 
diff --git a/noxfile.py b/noxfile.py
index ea45369a8..5b6449706 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -3,7 +3,7 @@
 import nox
 
 
-_py_versions = range(9, 12)
+_py_versions = range(9, 13)
 
 
 @nox.session(python=[f"3.{v}" for v in _py_versions])
@@ -19,20 +19,41 @@ def test_slow(session):
 
 @nox.session(python=[f"3.{v}" for v in _py_versions])
 def test(session):
-    session.install(".[test]")
-    session.chdir("tests")
+    session.install("-e.[test]")
+    # doctest modules within cogent3/app
+    session.chdir("src/cogent3/app")
+    session.run(
+        "pytest",
+        "-s",
+        "-x",
+        "--doctest-modules",
+        ".",
+    )
+
+    session.chdir("../../../tests")
     session.run(
         "pytest",
         "-s",
         "-x",
-        "--cov-report",
-        f"lcov:lcov-{session.python}.info",
-        "--cov",
-        "cogent3",
         "--ignore",
         "test_app_mpi.py",
         "-m",
         "not slow",
+        *session.posargs,
+    )
+
+
+@nox.session(python=[f"3.{v}" for v in _py_versions])
+def test_module_docs(session):
+    """doctest examples in a module"""
+    session.install(".[test]")
+    # doctest modules within cogent3/app
+    session.chdir("src/cogent3/app")
+    session.run(
+        "pytest",
+        "-s",
+        "--doctest-modules",
+        ".",
     )
 
 
diff --git a/pyproject.toml b/pyproject.toml
index e72cac35d..8aac8d9f8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi"
 name = "cogent3"
 description = """COmparative GENomics Toolkit 3: genomic sequence analysis within notebooks or on compute systems with 1000s of CPUs."""
 # remember to update cogent3/_version.py too!
-version = "2023.12.15a1"
+version = "2024.5.7a1"
 authors = [
     { name = "Gavin Huttley", email = "Gavin.Huttley@anu.edu.au"},
 ]
@@ -14,16 +14,19 @@ keywords = ["biology", "genomics", "statistics", "phylogeny", "evolution", "bioi
 readme = "README.md"
 license = { file = "LICENSE" }
 dependencies = ["chardet",
-        "numpy",
-        "numba>0.53; python_version>='3.9'",
-        "numba>0.54; python_version>='3.10'",
+        # until we know all dependencies work on numpy 2, pin to lesser version
+        "numpy<2",
+        "numba>0.53; python_version>='3.9' and python_version <'3.12'",
+        "numba>0.54; python_version>='3.10' and python_version <'3.12'",
         "numba>=0.57.0; python_version=='3.11'",
+        "numba>=0.59.0; python_version=='3.12'",
         "scipy",
         "scitrack",
+        "stevedore",
         "tqdm",
         "typing_extensions"]
 # remember to update version in requires-python and classifiers
-requires-python = ">=3.9,<3.12"
+requires-python = ">=3.9,<3.13"
 classifiers = [
         "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Science/Research",
@@ -34,6 +37,7 @@ classifiers = [
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
         "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
     ]
 
 [project.urls]
@@ -44,8 +48,8 @@ Changelog = "https://github.com/cogent3/cogent3/blob/develop/changelog.md"
 
 [project.optional-dependencies]
 test = [
-    "black==23.9.1",
-    "isort==5.12.0",
+    "black==24.4.2",
+    "isort==5.13.2",
     "click==8.1.7", # black needs this pinned
     "kaleido",
     "pandas",
@@ -81,13 +85,13 @@ doc  = ["click==8.1.7",
         "sphinxcontrib-bibtex",
         "sphinxcontrib-video"]
 extra = ["pandas", "plotly", "psutil", "kaleido"]
-dev = ["black==23.9.1",
+dev = ["black==24.4.2",
        "click==8.1.7",
        "flit",
        "ipykernel",
        "ipython",
        "ipywidgets",
-       "isort==5.12.0",
+       "isort==5.13.2",
        "jupyter-sphinx",
        "jupyter_client",
        "jupyterlab",
@@ -123,6 +127,7 @@ exclude = ["doc/*.html"]
 [tool.pytest.ini_options]
 markers = [
     "slow: marks tests as slow (deselect with '-m \"not slow\"')",
+    "internet: marks tests that require internet access  (deselect with '-m \"not internet\"')"
     ]
 testpaths = "tests"
 addopts = ["--strict-config"]
@@ -135,7 +140,7 @@ filterwarnings = [
 
 [tool.black]
 line-length = 88
-target-version = ["py39", "py310", "py311"]
+target-version = ["py39", "py310", "py311", "py312"]
 exclude = '''
 /(
     \.eggs
@@ -169,3 +174,51 @@ version="literal: src/cogent3/_version.py:__version__"
 skip_fragments="README.*"
 new_fragment_template="file: changelog.d/templates/new.md.j2"
 entry_title_template="file: changelog.d/templates/title.md.j2"
+
+[project.entry-points."cogent3.app"]
+concat = "cogent3.app.sample:concat"
+fixed_length = "cogent3.app.sample:fixed_length"
+load_aligned = "cogent3.app.io:load_aligned"
+load_db = "cogent3.app.io:load_db"
+load_json = "cogent3.app.io:load_json"
+load_tabular = "cogent3.app.io:load_tabular"
+load_unaligned = "cogent3.app.io:load_unaligned"
+min_length = "cogent3.app.sample:min_length"
+omit_bad_seqs = "cogent3.app.sample:omit_bad_seqs"
+omit_degenerates = "cogent3.app.sample:omit_degenerates"
+omit_duplicated = "cogent3.app.sample:omit_duplicated"
+omit_gap_pos = "cogent3.app.sample:omit_gap_pos"
+select_translatable = "cogent3.app.translate:select_translatable"
+take_codon_positions = "cogent3.app.sample:take_codon_positions"
+take_n_seqs = "cogent3.app.sample:take_n_seqs"
+take_named_seqs = "cogent3.app.sample:take_named_seqs"
+translate_seqs = "cogent3.app.translate:translate_seqs"
+trim_stop_codons = "cogent3.app.sample:trim_stop_codons"
+write_db = "cogent3.app.io:write_db"
+write_json = "cogent3.app.io:write_json"
+write_seqs = "cogent3.app.io:write_seqs"
+write_tabular = "cogent3.app.io:write_tabular"
+fast_slow_dist = "cogent3.app.dist:fast_slow_dist"
+jaccard_dist = "cogent3.app.dist:jaccard_dist"
+approx_pdist = "cogent3.app.dist:approx_pdist"
+approx_jc69 = "cogent3.app.dist:approx_jc69"
+gap_dist = "cogent3.app.dist:gap_dist"
+scale_branches = "cogent3.app.tree:scale_branches"
+uniformize_tree = "cogent3.app.tree:uniformize_tree"
+quick_tree = "cogent3.app.tree:quick_tree"
+align_to_ref = "cogent3.app.align:align_to_ref"
+progressive_align = "cogent3.app.align:progressive_align"
+smith_waterman = "cogent3.app.align:smith_waterman"
+ic_score = "cogent3.app.align:ic_score"
+cogent3_score = "cogent3.app.align:cogent3_score"
+sp_score = "cogent3.app.align:sp_score"
+model = "cogent3.app.evo:model"
+model_collection = "cogent3.app.evo:model_collection"
+hypothesis = "cogent3.app.evo:hypothesis"
+bootstrap = "cogent3.app.evo:bootstrap"
+ancestral_states = "cogent3.app.evo:ancestral_states"
+tabulate_stats = "cogent3.app.evo:tabulate_stats"
+natsel_neutral = "cogent3.app.evo:natsel_neutral"
+natsel_zhang = "cogent3.app.evo:natsel_zhang"
+natsel_sitehet = "cogent3.app.evo:natsel_sitehet"
+natsel_timehet = "cogent3.app.evo:natsel_timehet"
diff --git a/requirements.txt b/requirements.txt
index 5da4f1f52..f0bc64554 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,13 +1,14 @@
 chardet==5.2.0
-pillow==10.1.0
-psutil==5.9.6
-numpy==1.26.2
+pillow==10.3.0
+psutil==5.9.8
+numpy==1.26.4
 numba>0.48.0;python_version<'3.9'
 numba>0.53; python_version>='3.9'
 numba>0.54; python_version>='3.10'
 numba>=0.57.0; python_version=='3.11'
-scipy==1.11.4
-nox==2023.4.22
-plotly==5.18.0
+scipy==1.13.0
+nox==2024.4.15
+plotly==5.22.0
 typing_extensions>=3.7
+stevedore==5.2.0
 .[dev]
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index ac4d511a7..7cd610989 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -95,9 +95,7 @@ def make_seq(seq, name=None, moltype=None):
     """
     moltype = moltype or "text"
     moltype = get_moltype(moltype)
-    seq = moltype.make_seq(seq)
-    if name is not None:
-        seq.name = name
+    seq = moltype.make_seq(seq, name=name)
     return seq
 
 
diff --git a/src/cogent3/_version.py b/src/cogent3/_version.py
index f53842dae..b8ce52858 100644
--- a/src/cogent3/_version.py
+++ b/src/cogent3/_version.py
@@ -1,2 +1,2 @@
 # remember to update version in pyproject.toml too!
-__version__ = "2023.12.15a1"
+__version__ = "2024.5.7a1"
diff --git a/src/cogent3/align/pairwise.py b/src/cogent3/align/pairwise.py
index c517092e1..2214f0edb 100644
--- a/src/cogent3/align/pairwise.py
+++ b/src/cogent3/align/pairwise.py
@@ -13,6 +13,7 @@
 
 from cogent3.align.traceback import alignment_traceback, map_traceback
 from cogent3.core.alignment import Aligned
+from cogent3.core.location import IndelMap
 from cogent3.evolve.likelihood_tree import LikelihoodTreeEdge
 from cogent3.util.misc import ascontiguousarray
 
@@ -477,8 +478,20 @@ def _calcAligneds(self, children):
         aligneds = []
         for dim, child in enumerate(children):
             for seq_name, aligned in child.aligneds:
-                aligned = aligned.remapped_to((maps[dim] * word_length).inverse())
-                aligneds.append((seq_name, aligned))
+                # if word_length != 1 then maps are forced to have
+                # sequences lengths that are modulo word_length
+                new_map = aligned.map.merge_maps(
+                    maps[dim] * word_length,
+                    parent_length=maps[dim].parent_length * word_length,
+                )
+                # Likewise, if the data is not modulo word_length,
+                # it is trimmed to match
+                data = (
+                    aligned.data
+                    if new_map.parent_length == len(aligned.data)
+                    else aligned.data[: new_map.parent_length]
+                )
+                aligneds.append((seq_name, Aligned(new_map, data)))
         return aligneds
 
     def backward(self):
@@ -526,7 +539,11 @@ def __init__(self, leaf):
         _Alignable.__init__(self, leaf)
         if hasattr(leaf, "sequence"):
             self.seq = leaf.sequence
-            aligned = Aligned([(0, len(self.seq))], self.seq, len(self.seq))
+            seqlen = len(self.seq)
+            imap = IndelMap.from_aligned_segments(
+                locations=[(0, seqlen)], aligned_length=seqlen
+            )
+            aligned = Aligned(imap, self.seq)
             self.aligneds = [(self.leaf.edge_name, aligned)]
         self.max_preds = 1
         self._pog = None
diff --git a/src/cogent3/align/progressive.py b/src/cogent3/align/progressive.py
index d3f167b5d..0b09b1dac 100644
--- a/src/cogent3/align/progressive.py
+++ b/src/cogent3/align/progressive.py
@@ -6,7 +6,6 @@
 from cogent3.evolve.distance import EstimateDistances
 from cogent3.phylo import nj as NJ
 from cogent3.util import progress_display as UI
-from cogent3.util import warning as c3warn
 
 
 @UI.display_wrap
diff --git a/src/cogent3/align/traceback.py b/src/cogent3/align/traceback.py
index 626d30c09..39e27bbcf 100644
--- a/src/cogent3/align/traceback.py
+++ b/src/cogent3/align/traceback.py
@@ -1,9 +1,15 @@
-#!/usr/bin/env python
 """Conversion of dynamic program results ("arrays of arrows") into gap vectors,
 gapped sequences or Cogent Alignment objects"""
 
+import typing
+
 from cogent3.core.alignment import Aligned, Alignment
-from cogent3.core.annotation import Map
+from cogent3.core.location import IndelMap
+
+
+IntOrNone = typing.Union[int, None]
+IntListType = list[int]
+CoordsListType = list[list[typing.Sequence[int]]]
 
 
 def seq_traceback(s1, s2, aligned_positions, gap_value):
@@ -30,7 +36,9 @@ def seq_traceback(s1, s2, aligned_positions, gap_value):
     return alignments
 
 
-def gap_traceback(aligned_positions):
+def gap_traceback(
+    aligned_positions: list[list[IntOrNone, IntOrNone]]
+) -> tuple[IntListType, IntListType, CoordsListType, int]:
     """gap Vectors from state matrix and ending point"""
     consuming = [False, False]
     starts = [None, None]
@@ -52,15 +60,19 @@ def gap_traceback(aligned_positions):
         if consuming[dimension]:
             gv.append(a)
         gap_vectors[dimension] = [(gv[i], gv[i + 1]) for i in range(0, len(gv), 2)]
-    return (starts, ends, gap_vectors, a)
+    return starts, ends, gap_vectors, a
 
 
-def map_traceback(aligned_positions):
-    # using Map's to keep track of gaps for indel alignment
-    (starts, ends, gap_vectors, alignment_len) = gap_traceback(aligned_positions)
-    # print 'gv', gap_vectors
-    maps = [Map(gv, parent_length=alignment_len).inverse() for gv in gap_vectors]
-    return (starts, ends, maps)
+def map_traceback(
+    aligned_positions: list[list[IntOrNone, IntOrNone]]
+) -> tuple[IntListType, IntListType, list[IndelMap]]:
+    # using IndelMap's to keep track of gaps for indel alignment
+    starts, ends, gap_vectors, alignment_len = gap_traceback(aligned_positions)
+    maps = [
+        IndelMap.from_aligned_segments(locations=gv, aligned_length=alignment_len)
+        for gv in gap_vectors
+    ]
+    return starts, ends, maps
 
 
 def alignment_traceback(seqs, aligned_positions, word_length):
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index c126c3eca..b31b065d9 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -1,41 +1,45 @@
 from __future__ import annotations
 
 import contextlib
-import importlib
 import inspect
 import re
 import textwrap
+import warnings
+
+import stevedore
 
 from cogent3.util.table import Table
 
+from .composable import is_app, is_app_composable
 from .io import open_data_store
 
 
-__all__ = [
-    "align",
-    "composable",
-    "dist",
-    "evo",
-    "io",
-    "sample",
-    "translate",
-    "tree",
-]
+# Entry_point for apps to register themselves as plugins
+APP_ENTRY_POINT = "cogent3.app"
+
+
+def _get_extension_attr(extension):
+    """
+    This function returns app details for display.
 
+    Notes
+    -----
+    This function also loads the module the app is in.
+    """
 
-def _get_app_attr(name, is_composable):
-    """returns app details for display"""
+    obj = extension.plugin
 
-    modname, name = name.rsplit(".", maxsplit=1)
-    mod = importlib.import_module(modname)
-    obj = getattr(mod, name)
+    if not is_app(obj):
+        warnings.warn(
+            f"{obj!r} from {obj.__module__!r} is not a valid cogent3 app, skipping"
+        )
 
     _types = _make_types(obj)
 
     return [
-        mod.__name__,
-        name,
-        is_composable,
+        extension.module_name,
+        extension.name,
+        is_app_composable(obj),
         _doc_summary(obj.__doc__ or ""),
         ", ".join(sorted(_types["_data_types"])),
         ", ".join(sorted(_types["_return_types"])),
@@ -52,36 +56,40 @@ def _make_types(app) -> dict:
     return _types
 
 
-def available_apps(name_filter: str | None = None) -> Table:
-    """returns Table listing the available apps
+# private global to hold an ExtensionManager instance
+__apps = None
 
-    Parameters
-    ----------
-    name_filter
-        include apps whose name includes name_filter
+
+def get_app_manager() -> stevedore.ExtensionManager:
     """
-    from cogent3.util.table import Table
+    Lazy load a stevedore ExtensionManager to collect apps.
+    """
+    global __apps
+    if not __apps:
+        __apps = stevedore.ExtensionManager(
+            namespace=APP_ENTRY_POINT, invoke_on_load=False
+        )
 
-    from .composable import __app_registry
+    return __apps
 
-    # registration of apps does not happen until their modules are imported
-    for name in __all__:
-        importlib.import_module(f"cogent3.app.{name}")
 
-    # exclude apps from deprecated modules
-    deprecated = []
+def available_apps(name_filter: str | None = None) -> Table:
+    """
+    returns Table listing the available apps
+    """
+    from cogent3.util.table import Table
 
     rows = []
-    for app, is_comp in __app_registry.items():
-        if any(app.startswith(d) for d in deprecated):
-            continue
 
-        if name_filter and name_filter not in app:
+    extensions = get_app_manager()
+
+    for extension in extensions:
+        if name_filter and name_filter not in extension.name:
             continue
 
         with contextlib.suppress(AttributeError):
             # probably a local scope issue in testing!
-            rows.append(_get_app_attr(app, is_comp))
+            rows.append(_get_extension_attr(extension))
 
     header = ["module", "name", "composable", "doc", "input type", "output type"]
     return Table(header=header, data=rows)
@@ -93,6 +101,13 @@ def available_apps(name_filter: str | None = None) -> Table:
 def _make_signature(app: type) -> str:
     from cogent3.util.misc import get_object_provenance
 
+    if app is None:
+        raise ValueError("app cannot be None")
+
+    # if app is an instance, get the underlying class
+    if not inspect.isclass(app):
+        app = app.__class__
+
     init_sig = inspect.signature(app.__init__)
     app_name = app.__name__
     params = [f'"{app_name}"']
@@ -141,46 +156,39 @@ def _doc_summary(doc):
 
 def _get_app_matching_name(name: str):
     """name can include module name"""
-    table = available_apps()
+
     if "." in name:
         modname, name = name.rsplit(".", maxsplit=1)
-        table = table.filtered(
-            lambda x: x[0].endswith(modname) and x[1] == name,
-            columns=["module", "name"],
-        )
     else:
-        table = table.filtered(lambda x: name == x, columns="name")
+        modname = None
 
-    if table.shape[0] == 0:
-        raise NameError(f"no app matching name {name!r}")
-    elif table.shape[0] > 1:
-        raise NameError(f"too many apps matching name {name!r},\n{table}")
-    modname, _ = table.to_list(columns=["module", "name"])[0]
-    mod = importlib.import_module(modname)
-    return getattr(mod, name)
+    extensions_matching = [
+        extension for extension in get_app_manager() if extension.name == name
+    ]
+    if not extensions_matching:
+        raise ValueError(f"App {name!r} not found. Please check for typos.")
 
+    if modname:
+        for extension in extensions_matching:
+            if extension.module_name.endswith(modname):
+                return extension.plugin
+        raise ValueError(f"App {name!r} not found. Please check for typos.")
 
-def get_app(_app_name: str, *args, **kwargs):
-    """returns app instance, use app_help() to display arguments
+    elif len(extensions_matching) == 1:
+        return extensions_matching[0].plugin
+    else:
+        raise NameError(
+            f"Too many apps matching name {name!r},\n{available_apps().filtered(lambda x: name == x, columns='name')}"
+        )
 
-    Parameters
-    ----------
-    _app_name
-        app name, e.g. 'minlength', or can include module information,
-        e.g. 'cogent3.app.sample.minlength' or 'sample.minlength'. Use the
-        latter (qualified class name) style when there are multiple matches
-        to name.
-    *args, **kwargs
-        positional and keyword arguments passed through to the app
 
-    Returns
-    -------
-    cogent3 app instance
+def get_app(_app_name: str, *args, **kwargs):
+    """returns app instance, use app_help() to display arguments
 
     Raises
     ------
     NameError when multiple apps have the same name. In that case use a
-    qualified class name, as shown above.
+    qualified class name.
     """
     return _get_app_matching_name(_app_name)(*args, **kwargs)
 
@@ -197,8 +205,13 @@ def _clean_params_docs(text: str) -> str:
     doc = []
     for line in text:
         line = prefix.sub("", line)
-        if line.strip():
-            doc.append(line)
+        doc.append(line)
+
+    # Remove empty lines at the beginning and end of docstrings
+    if not doc[0]:
+        doc.pop(0)
+    if not doc[-1]:
+        doc.pop()
 
     return "\n".join(doc)
 
@@ -208,24 +221,14 @@ def _clean_overview(text: str) -> str:
     return "\n".join(textwrap.wrap(" ".join(text), break_long_words=False))
 
 
-def app_help(name: str):
-    """displays help for the named app
-
-    Parameters
-    ----------
-    name
-        app name, e.g. 'minlength', or can include module information,
-        e.g. 'cogent3.app.sample.minlength' or 'sample.minlength'. Use the
-        latter (qualified class name) style when there are multiple matches
-        to name.
-    """
-    app = _get_app_matching_name(name)
+def _make_apphelp_docstring(app):
     docs = []
     app_doc = app.__doc__ or ""
     if app_doc.strip():
-        docs.extend(_make_head("Overview") + [_clean_overview(app_doc), ""])
+        docs.extend(_make_head("Overview") + [_clean_overview(app_doc)])
 
     docs.extend(_make_head("Options for making the app") + [_make_signature(app)])
+
     init_doc = app.__init__.__doc__ or ""
     if init_doc.strip():
         docs.extend(["", _clean_params_docs(init_doc)])
@@ -234,4 +237,19 @@ def app_help(name: str):
     docs.extend([""] + _make_head("Input type") + [", ".join(types["_data_types"])])
     docs.extend([""] + _make_head("Output type") + [", ".join(types["_return_types"])])
 
-    print("\n".join(docs))
+    return "\n".join(docs)
+
+
+def app_help(name: str):
+    """displays help for the named app
+
+    Parameters
+    ----------
+    name
+        app name, e.g. 'min_length', or can include module information,
+        e.g. 'cogent3.app.sample.min_length' or 'sample.min_length'. Use the
+        latter (qualified class name) style when there are multiple matches
+        to name.
+    """
+    app = _get_app_matching_name(name)
+    print(_make_apphelp_docstring(app))
diff --git a/src/cogent3/app/align.py b/src/cogent3/app/align.py
index 34c83c3e9..50145b98e 100644
--- a/src/cogent3/app/align.py
+++ b/src/cogent3/app/align.py
@@ -45,7 +45,7 @@ class _GapOffset:
     From alignment coordinate to a sequence coordinate
 
     >>> aln2seq = _GapOffset({1:3, 7:1}, invert=True)
-    >>> seq_pos = aln_pos - aln2seq[seq_pos]
+    >>> seq_pos = aln_pos - aln2seq[aln_pos]
     >>> seq_pos
     2
     """
@@ -60,25 +60,24 @@ def __init__(self, gaps_lengths, invert=False):
         invert : bool
             if True, query keys are taken as being in alignment coordinates
         """
-        offset = 0
-        min_val = None
+        cum_gap_length = 0
         result = {}
-        k = -1
-        for k, l in sorted(gaps_lengths.items()):
+        gap_pos = -1
+        for gap_pos, gap_length in sorted(gaps_lengths.items()):
             if invert:
-                result[k + offset + l] = offset + l
-                result[k + offset] = offset
+                result[gap_pos + cum_gap_length] = cum_gap_length
+                result[gap_pos + cum_gap_length + gap_length] = (
+                    cum_gap_length + gap_length
+                )
             else:
-                result[k] = offset
+                result[gap_pos] = cum_gap_length
 
-            offset += l
-            if min_val is None:
-                min_val = k
+            cum_gap_length += gap_length
 
         self._store = result
-        self.min_pos = min_val
-        self.max_pos = k + offset if invert else k
-        self.total = offset
+        self.min_pos = min(gaps_lengths) if gaps_lengths else None
+        self.max_pos = gap_pos + cum_gap_length if invert else gap_pos
+        self.total = cum_gap_length
         self._ordered = None
         self._invert = invert
 
@@ -88,27 +87,27 @@ def __repr__(self):
     def __str__(self):
         return str(self._store)
 
-    def __getitem__(self, k):
+    def __getitem__(self, index):
         if not self._store:
             return 0
 
-        if k in self._store:
-            return self._store[k]
+        if index in self._store:
+            return self._store[index]
 
-        if k < self.min_pos:
+        if index < self.min_pos:
             return 0
 
-        if k > self.max_pos:
+        if index > self.max_pos:
             return self.total
 
         if self._ordered is None:
             self._ordered = sorted(self._store)
 
-        # k is definitely bounded by min and max positions here
-        i = bisect_left(self._ordered, k)
+        # index is definitely bounded by min and max positions here
+        i = bisect_left(self._ordered, index)
         pos = self._ordered[i]
         if self._invert:
-            pos = pos if pos in [k, 0] else self._ordered[i - 1]
+            pos = pos if pos in [index, 0] else self._ordered[i - 1]
         return self._store[pos]
 
 
@@ -172,7 +171,7 @@ def _merged_gaps(a_gaps: dict, b_gaps: dict) -> dict:
     function to 'max'. Use 'sum' when the gaps derive from different
     sequences.
     """
-
+    # todo convert to using IndelMap functions
     if not a_gaps:
         return b_gaps
 
@@ -252,7 +251,8 @@ def _gaps_for_injection(other_seq_gaps: dict, refseq_gaps: dict, seqlen: int) ->
     # sequence coordinates
     # we probably need to include the refseq gap union because we need to
     # establish whether a refseq gap overlaps with a gap in other seq
-    # and
+    # todo convert these functions to using IndelMap and the numpy set
+    #  operation functions
     all_gaps = {}
     all_gaps.update(other_seq_gaps)
     for gap_pos, gap_length in sorted(refseq_gaps.items()):
@@ -305,8 +305,7 @@ def pairwise_to_multiple(pwise, ref_seq, moltype, info=None):
         other_seq = aln.named_seqs[other_name]
         other_gaps = dict(other_seq.map.get_gap_coordinates())
         diff_gaps = _combined_refseq_gaps(curr_ref_gaps, ref_gaps)
-        inject = _gaps_for_injection(other_gaps, diff_gaps, len(other_seq.data))
-        if inject:
+        if inject := _gaps_for_injection(other_gaps, diff_gaps, len(other_seq.data)):
             m = gap_coords_to_map(inject, len(other_seq.data))
             other_seq = Aligned(m, other_seq.data)
 
@@ -460,6 +459,35 @@ def __init__(
             if no guide tree, and model is for DNA / Codons, estimates pairwise
             distances using an approximation and JC69. Otherwise, estimates
             genetic distances from pairwise alignments (which is slower).
+
+        Examples
+        --------
+
+        Create an unaligned sequence collection of BRCA1 genes from 4 species,
+        and an app for alignment with nucleotide model ``model="HKY85"``.
+
+        >>> from cogent3 import make_unaligned_seqs, get_app
+        >>> aln = make_unaligned_seqs({
+        ... "Human": "GCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTCACT",
+        ... "Bandicoot": "NACTCATTAATGCTTGAAACCAGCAGTTTATTGTCCAAC",
+        ... "Rhesus": "GCCAGCTCATTACAGCATGAGAACAGTTTGTTACTCACT",
+        ... "FlyingFox": "GCCAGCTCTTTACAGCATGAGAACAGTTTATTATACACT"
+        ... }, moltype="dna")
+        >>> app = get_app("progressive_align", model="HKY85")
+        >>> result = app(aln)
+        >>> print(result.to_pretty(name_order=['Human', 'Bandicoot', 'Rhesus', 'FlyingFox']))
+            Human    GCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTCACT...
+
+        Optionally, a pre-computed guide tree can be provided.
+
+        >>> newick = "(Bandicoot:0.4,FlyingFox:0.05,(Rhesus:0.06," "Human:0.0):0.04);"
+        >>> app_guided = get_app("progressive_align", model="HKY85", guide_tree=newick)
+        >>> result = app_guided(aln)
+        >>> print(result.to_pretty(name_order=['Human', 'Bandicoot', 'Rhesus', 'FlyingFox']))
+            Human    GCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTCACT
+        Bandicoot    NA.TCA.T.A.G.TTG.AACC.G...---......GTC..AC
+           Rhesus    ..........................---...G.........
+        FlyingFox    ........T.................---.......TA....
         """
         self._param_vals = {
             "codon": dict(omega=0.4, kappa=3),
@@ -651,6 +679,19 @@ def __init__(self, equifreq_mprobs=True):
         ----------
         equifreq_mprobs : bool
             If true, specifies equally frequent motif probabilities.
+
+        Examples
+        --------
+
+        Create a sample alignment and compute the Information Content alignment
+        quality score. The default is equally frequent motif probabilities.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "AATTGA", "s2": "AGGTCC", "s3": "AGGATG", "s4": "AGGCGT"})
+        >>> app = get_app("ic_score")
+        >>> result = app(aln)
+        >>> print(result)
+        5.377443751081734
         """
         self._equi_frequent = equifreq_mprobs
 
@@ -709,6 +750,32 @@ def cogent3_score(aln: AlignedSeqsType) -> float:
     The instance must have been aligned using cogent3 tree_align. In addition,
     if the alignment has been saved, it has must have been serialised
     using a format that preserves the score.
+
+    Examples
+    --------
+
+    Prepare an alignment of BRCA1 genes from 4 species. Create an unaligned
+    sequence collection, guide tree, and an app for alignment using cogent3
+    ``progressive_align()``.
+
+    >>> from cogent3 import make_unaligned_seqs, get_app
+    >>> aln = make_unaligned_seqs({
+    ... "Human": "GCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTCACT",
+    ... "Bandicoot": "NACTCATTAATGCTTGAAACCAGCAGTTTATTGTCCAAC",
+    ... "Rhesus": "GCCAGCTCATTACAGCATGAGAACAGTTTGTTACTCACT",
+    ... "FlyingFox": "GCCAGCTCTTTACAGCATGAGAACAGTTTATTATACACT"
+    ... }, moltype="dna")
+    >>> newick = "(Bandicoot:0.4,FlyingFox:0.05,(Rhesus:0.06," "Human:0.0):0.04);"
+    >>> aligner = get_app("progressive_align", model="HKY85")
+
+    Create a composable app that aligns the sequences and returns the
+    cogent3 log-likelihood alignment score.
+
+    >>> scorer = get_app("cogent3_score")
+    >>> app = aligner + score
+    >>> result = app(aln)
+    >>> print(result)
+    -130.47375615734916
     """
     if aln.num_seqs == 1 or len(aln) == 0:
         msg = "zero length" if len(aln) == 0 else "single sequence"
@@ -767,6 +834,27 @@ def __init__(
         Notes
         -----
         see available_distances() for the list of available methods.
+
+        Examples
+        --------
+
+        Create a sample alignment and an app to calculate the Sum of Pairs
+        alignment score with ``calc="pdist"`` and no gap penalties.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "AAGAA-A", "s2": "-ATAATG", "s3": "C-TGG-G"})
+        >>> app = get_app("sp_score", calc="pdist", gap_extend=0, gap_insert=0)
+        >>> result = app(aln)
+        >>> print(result)
+        5.0
+
+        Penalise gap extensions with ``gap_extend=1`` and insertions with
+        ``gap_insert=2``.
+
+        >>> app_gap_penalty = get_app("sp_score", calc="pdist", gap_extend=1, gap_insert=2)
+        >>> result = app_gap_penalty(aln)
+        >>> print(result)
+        -13.0
         """
         self._insert = gap_insert
         self._extend = gap_extend
diff --git a/src/cogent3/app/composable.py b/src/cogent3/app/composable.py
index 8ec880811..48d7fed47 100644
--- a/src/cogent3/app/composable.py
+++ b/src/cogent3/app/composable.py
@@ -401,9 +401,6 @@ def _validate_data_type(self, data):
     return valid
 
 
-__app_registry = {}
-
-
 def _class_from_func(func):
     """make a class based on func
 
@@ -475,22 +472,30 @@ class with the function bound as a static method.
     sequentially invokes the composed apps. For example, the independent
     usage of app instances ``app1`` and ``app2`` as
 
-    >>> app2(app1(data))
+    .. code-block:: python
+
+        app2(app1(data))
 
     is equivalent to
 
-    >>> combined = app1 + app2
-    >>> combined(data)
+    .. code-block:: python
+
+        combined = app1 + app2
+        combined(data)
 
     The ``app_type`` attribute is used to constrain how apps can be composed.
     ``LOADER`` and ``WRITER`` are special cases. If included, a ``LOADER``
     must always be first, e.g.
 
-    >>> app = a_loader + a_generic
+    .. code-block:: python
+
+        app = a_loader + a_generic
 
     If included, a ``WRITER`` must always be last, e.g.
 
-    >>> app = a_generic + a_writer
+    .. code-block:: python
+
+        app = a_generic + a_writer
 
     Changing the order for either of the above will result in a ``TypeError``.
 
@@ -516,13 +521,16 @@ class with the function bound as a static method.
     the type hint on the first argument of main it is passed to ``app.main()``.
     If it does not match, a new ``NotCompleted`` instance is returned.
 
+    Examples
+    --------
+
     An example app definition.
 
     >>> from typing import Union
     >>> from cogent3.app.composable import define_app
     >>> from cogent3.app.typing import AlignedSeqsType, SerialisableType
-    ...
-    ... @define_app
+    >>>
+    >>> @define_app
     ... class drop_bad:
     ...     def __init__(self, quantile=None, gap_fraction=1, moltype="dna"):
     ...         self.quantile = quantile
@@ -595,7 +603,10 @@ def wrapped(klass):
             raise TypeError(
                 f"remove 'input' attribute in {klass.__name__!r}, this functionality provided by define_app"
             )
-
+        if composable and getattr(klass, "__add__", None):
+            raise TypeError(
+                f"remove '__add__' method in {klass.__name__!r}, this functionality provided by define_app"
+            )
         for meth in method_list:
             # make sure method not defined by user before adding
             if inspect.isfunction(getattr(klass, meth, None)):
@@ -620,8 +631,6 @@ def wrapped(klass):
             # not supporting this yet
             raise NotImplementedError("slots are not currently supported")
 
-        # register this app
-        __app_registry[get_object_provenance(klass)] = composable
         return klass
 
     return wrapped(klass) if klass else wrapped
@@ -691,9 +700,14 @@ def _as_completed(self, dstore, parallel=False, par_kw=None, **kwargs) -> Genera
     return ui.series(to_do, count=len(mapped), **kwargs)
 
 
-def is_composable(obj):
-    """checks whether obj has been registered by the composable decorator"""
-    return __app_registry.get(get_object_provenance(obj), False)
+def is_app_composable(obj):
+    """checks whether obj has been decorated by define_app and it's app_type attribute is not NON_COMPOSABLE"""
+    return is_app(obj) and obj.app_type is not NON_COMPOSABLE
+
+
+def is_app(obj):
+    """checks whether obj has been decorated by define_app"""
+    return hasattr(obj, "app_type")
 
 
 def _apply_to(
@@ -777,7 +791,7 @@ def _apply_to(
         inputs, parallel=parallel, par_kw=par_kw, show_progress=show_progress
     ):
         member = self.main(data=result.obj, identifier=id_from_source(result.source))
-        md5 = member.md5
+        md5 = getattr(member, "md5", None)
         logger.log_message(str(member), label="output")
         if md5:
             logger.log_message(md5, label="output md5sum")
diff --git a/src/cogent3/app/data_store.py b/src/cogent3/app/data_store.py
index 58ebf458f..9b2e1c02e 100644
--- a/src/cogent3/app/data_store.py
+++ b/src/cogent3/app/data_store.py
@@ -62,13 +62,11 @@ class DataMemberABC(ABC):
 
     @property
     @abstractmethod
-    def data_store(self) -> DataStoreABC:
-        ...
+    def data_store(self) -> DataStoreABC: ...
 
     @property
     @abstractmethod
-    def unique_id(self):
-        ...
+    def unique_id(self): ...
 
     def __str__(self):
         return self.unique_id
@@ -123,8 +121,7 @@ def mode(self) -> Mode:
 
     @property
     @abstractmethod
-    def limit(self):
-        ...
+    def limit(self): ...
 
     def __repr__(self):
         name = self.__class__.__name__
@@ -150,8 +147,7 @@ def __contains__(self, identifier):
         return any(m.unique_id == identifier for m in self)
 
     @abstractmethod
-    def read(self, unique_id: str) -> StrOrBytes:
-        ...
+    def read(self, unique_id: str) -> StrOrBytes: ...
 
     def _check_writable(self, unique_id: str):
         if self.mode is READONLY:
@@ -180,18 +176,15 @@ def __iter__(self):
 
     @property
     @abstractmethod
-    def logs(self) -> list[DataMemberABC]:
-        ...
+    def logs(self) -> list[DataMemberABC]: ...
 
     @property
     @abstractmethod
-    def completed(self) -> list[DataMemberABC]:
-        ...
+    def completed(self) -> list[DataMemberABC]: ...
 
     @property
     @abstractmethod
-    def not_completed(self) -> list[DataMemberABC]:
-        ...
+    def not_completed(self) -> list[DataMemberABC]: ...
 
     @property
     def summary_logs(self) -> TabularType:
@@ -251,8 +244,7 @@ def describe(self) -> TabularType:
         )
 
     @abstractmethod
-    def drop_not_completed(self, *, unique_id: Optional[str] = None) -> None:
-        ...
+    def drop_not_completed(self, *, unique_id: Optional[str] = None) -> None: ...
 
     def validate(self) -> TabularType:
         correct_md5 = len(self.members)
diff --git a/src/cogent3/app/dist.py b/src/cogent3/app/dist.py
index 3f28ad04c..3d6937033 100644
--- a/src/cogent3/app/dist.py
+++ b/src/cogent3/app/dist.py
@@ -165,6 +165,19 @@ def jaccard_dist(seq_coll: UnalignedSeqsType, k: int = 10) -> PairwiseDistanceTy
     -------
     PairwiseDistanceType
         Pairwise Jaccard distance between sequences in the collection.
+
+    Examples
+    --------
+
+    Create an sample sequence collection and an app that calculates the
+    pairwise Jaccard distance with kmer size ``k=2``.
+
+    >>> from cogent3 import make_unaligned_seqs, get_app
+    >>> seqs = make_unaligned_seqs({"s1": "ACGTA", "s2": "ACGTC"}, moltype="dna")
+    >>> app = get_app("jaccard_dist", k=2)
+    >>> result = app(seqs)
+    >>> print(result.to_dict())
+    {('s1', 's2'): 0.4, ('s2', 's1'): 0.4}
     """
 
     kmers = {
@@ -223,6 +236,24 @@ def approx_pdist(jaccard_dists: PairwiseDistanceType) -> PairwiseDistanceType:
     polynomial fit between Jaccard distance and p-distance. The coefficients
     were fitted using data from 106 DNA sequences of mammalian protein coding
     genes, with kmers of size k=10.
+
+    Examples
+    --------
+
+    Create a sample sequence collection and apps for calculating the Jaccard
+    distances, and an approximation of the p-distance.
+
+    >>> from cogent3 import make_unaligned_seqs, get_app
+    >>> seqs = make_unaligned_seqs({"s1": "ACGTA", "s2": "ACGTC"}, moltype="dna")
+    >>> jaccard_dist = get_app("jaccard_dist", k=2)
+    >>> pdist = get_app("approx_pdist")
+
+    Create a composable app to return the p-distances.
+
+    >>> app = jaccard_dist + pdist
+    >>> result = app(seqs)
+    >>> print(result.to_dict())
+    {('s1', 's2'): 0.02597466474649073, ('s2', 's1'): 0.02597466474649073}
     """
     upper_indices = triu_indices(n=jaccard_dists.shape[0], k=1)
     result = deepcopy(jaccard_dists)  # so the original matrix not modified
@@ -253,6 +284,24 @@ def approx_jc69(
     Returns
     -------
     DistanceMatrix of pairwise JC69 distances
+
+    Examples
+    --------
+
+    Create a sample sequence collection and apps for calculating the Jaccard
+    distances, and an approximation of the pairwise JC69 distances.
+
+    >>> from cogent3 import make_unaligned_seqs, get_app
+    >>> seqs = make_unaligned_seqs({"s1": "ACGTA", "s2": "ACGTC"}, moltype="dna")
+    >>> jaccard_dist = get_app("jaccard_dist", k=2)
+    >>> jc69 = get_app("approx_jc69")
+
+    Create a composable app to return the JC69 distances.
+
+    >>> app = jaccard_dist + jc69
+    >>> result = app(seqs)
+    >>> print(result.to_dict())
+    {('s1', 's2'): 0.5716050390351726, ('s2', 's1'): 0.5716050390351726}
     """
     upper_indices = triu_indices(n=pdists.shape[0], k=1)
     result = deepcopy(pdists)  # so the original matrix not modified
diff --git a/src/cogent3/app/evo.py b/src/cogent3/app/evo.py
index 9ab8afd02..ce5630151 100644
--- a/src/cogent3/app/evo.py
+++ b/src/cogent3/app/evo.py
@@ -6,6 +6,7 @@
 from cogent3 import load_tree, make_tree
 from cogent3.core.tree import TreeNode
 from cogent3.evolve.models import get_model
+from cogent3.evolve.substitution_model import _SubstitutionModel
 from cogent3.util import parallel
 
 from .composable import NotCompleted, define_app
@@ -52,48 +53,49 @@ class model:
 
     def __init__(
         self,
-        sm,
-        tree=None,
-        unique_trees=False,
-        tree_func=None,
-        name=None,
-        optimise_motif_probs=False,
-        sm_args=None,
-        lf_args=None,
-        time_het=None,
-        param_rules=None,
-        opt_args=None,
-        lower=1e-6,
-        upper=50,
-        split_codons=False,
-        show_progress=False,
-        verbose=False,
+        sm: Union[str, _SubstitutionModel],
+        tree: Optional[Union[TreeNode, str]] = None,
+        unique_trees: bool = False,
+        tree_func: Optional[Callable] = None,
+        name: Optional[str] = None,
+        optimise_motif_probs: bool = False,
+        sm_args: Optional[dict] = None,
+        lf_args: Optional[dict[str, Union[list, str]]] = None,
+        time_het: Optional[Union[str, list[dict[str, Union[list, str]]]]] = None,
+        param_rules: Optional[list[dict[str, Union[list, str]]]] = None,
+        opt_args: Optional[dict] = None,
+        lower: float = 1e-6,
+        upper: float = 50,
+        split_codons: bool = False,
+        show_progress: bool = False,
+        verbose: bool = False,
     ):
         """
         Parameters
         ----------
-        sm : str or instance
-            substitution model if string must be available via get_model()
+        sm
+            substitution model (str or instance) if string must be available
+            via get_model()
         tree
             if None, assumes a star phylogeny (only valid for 3 taxa). Can be a
             newick formatted tree, a path to a file containing one, or a Tree
-            instance.
-        unique_trees : bool
+            instance
+        unique_trees
             whether to specify a unique tree per alignment. Only applies if
-            number of sequences equals 3.
+            number of sequences equals 3
         tree_func: callable
             a callable that takes an alignment and returns a Tree instance.
             Overrides tree and unique_tree settings.
-        name : str
-            name of the model
-        optimise_motif_probs : bool
+        name
+            the model name
+        optimise_motif_probs
             whether the motif probabilities are free parameters. If False,
             takes the average of frequencies from the alignment. Overrides
             the setting of a sub model instance, or any value provided in
-            sm_args.
-        sm_args : dict
+            sm_args
+        sm_args
             arguments to be passed to the substitution model constructor
-        lf_args : dict
+        lf_args
             arguments to be passed to the likelihood function constructor
         time_het
             Affects whether substitution model rate parameters are
@@ -104,22 +106,22 @@ def __init__(
             using a list of dicts corresponding to edge_sets, e.g.
             ``[dict(edges=['Human', 'Chimp'], is_independent=False, upper=10)]``.
             This value is passed to <likelihood function>.set_time_heterogeneity()
-        param_rules : list
+        param_rules
             other parameter rules, passed to
             <likelihood function>.set_param_rule()
-        opt_args : dict
+        opt_args
             arguments for the numerical optimiser, e.g.
             dict(max_restarts=5, tolerance=1e-6, max_evaluations=1000,
             limit_action='ignore')
         lower, upper
             bounds for all rate and length parameters. Ignored if a
             rule in ``param_rules`` or ``time_het`` has a value defined.
-        split_codons : bool
+        split_codons
             if True, incoming alignments are split into the 3 frames and each
             frame is fit separately
-        show_progress : bool
+        show_progress
             show progress bars during numerical optimisation
-        verbose : bool
+        verbose
             prints intermediate states to screen during fitting
 
         Returns
@@ -127,6 +129,121 @@ def __init__(
         Calling an instance with an alignment returns a model_result instance
         with the optimised likelihood function. In the case of split_codons,
         the result object has a separate entry for each codon position.
+
+        Examples
+        --------
+
+        Create a model and fit to a three-sequence alignment. For three
+        sequences, there is only one possible unrooted tree so we do not need
+        to provide one. (We're limiting the optimiser's workload by setting
+        ``max_evaluations=10``, solely to ensure quick execution of the examples, not
+        because we recommend it!)
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({
+        ...    "Human": "ATGCGGCTCGCGGAGGCCGCGCTCGCGGAG",
+        ...    "Mouse": "ATGCCCGGCGCCAAGGCAGCGCTGGCGGAG",
+        ...    "Opossum": "ATGCCAGTGAAAGTGGCGGCGGTGGCTGAG",
+        ... })
+        >>> app = get_app("model", "F81", opt_args=dict(limit_action="ignore",
+        ... max_evaluations=10))
+        >>> result = app(aln)
+        >>> result
+        F81...
+
+        For the following, we will only show different model construction options
+        but don't apply them to data.
+
+        To apply a model to an alignment with more than three sequences
+        we need to provide a tree. We can provide the tree as a newick
+        string.
+
+        >>> tree = "(Mouse,(Human,Gorilla),Opossum)"
+        >>> aln2 = make_aligned_seqs({
+        ...      "Human": "ATGCGGCTCGCGGAGGCCGCGCTCGCGGAG",
+        ...      "Gorilla": "ATGCGGCGCGCGGAGGCCGCGCTCGCGGAG",
+        ...      "Mouse": "ATGCCCGGCGCCAAGGCAGCGCTGGCGGAG",
+        ...      "Opossum": "ATGCCAGTGAAAGTGGCGGCGGTGGCTGAG",
+        ... })
+        >>> app_tr = get_app("model", "F81", tree=tree)
+
+        Or we could assign a function that estimates the tree for an alignment.
+
+        >>> dist_cal = get_app("fast_slow_dist", fast_calc="paralinear", moltype="dna")
+        >>> est_tree = get_app("quick_tree")
+        >>> tree_func = dist_cal + est_tree
+        >>> model = get_app("model", "F81", tree_func=tree_func)
+
+        We can specify a time-heterogeneous model (where substitution rate parameters
+        differ between branches). For details, see
+        https://cogent3.org/doc/app/evo-model-timehet
+
+        >>> app_thet = get_app(
+        ...    "model",
+        ...    "HKY85",
+        ...    tree=tree,
+        ...    time_het=[dict(tip_names=["Human", "Opossum"], outgroup_name="Mouse")],
+        ... )
+
+        Specify the upper and lower bounds for certain branch length and rate
+        exchangeability parameter.
+
+        >>> app_alt_params = get_app(
+        ...     "model",
+        ...     "HKY85",
+        ...     tree=tree,
+        ...     param_rules=[
+        ...         {"par_name": "length", "edge": "Human", "upper": 5, "lower": 1e-2},
+        ...         {"par_name": "kappa", "upper": 20, "lower": 1e-6},
+        ...     ],
+        ... )
+
+        Specify the settings in the optimiser. By default, the Powell local optimiser is used.
+        The Powell algorithm can use restarts, configured using ``max_restarts``, to overcome
+        local maxima. With ``limit_action="ignore"`` defined, the optimiser will disregard
+        optimisation failures caused by exceeding ``max_evaluations``, rather than meeting the
+        ``tolerance`` condition. (For more information see
+        https://cogent3.org/doc/cookbook/evo_modelling.html.)
+
+        >>> app_alt_opt = get_app(
+        ...     "model",
+        ...     "HKY85",
+        ...     tree=tree,
+        ...     opt_args=dict(
+        ...         max_restarts=5, tolerance=1e-8, max_evaluations=1_000_000, limit_action="ignore"
+        ...     ),
+        ... )
+
+        Specify settings in the likelihood function constructor.
+
+        >>> app_alt_lf = get_app(
+        ...     "model", "HKY85", tree=tree, lf_args = dict(discrete_edges=["Opossum"])
+        ... )
+
+        Splitting codons and fit models to each codon position class.
+
+        >>> app_sp_codon = get_app(
+        ...     "model", "HKY85", tree=tree, split_codons=True
+        ... )
+
+        A ``NotCompleted`` object (see https://cogent3.org/doc/app/not-completed.html)
+        is returned if ``tree`` (or ``tree_func``) is not provided and the number of seqs
+        exceeds 3.
+
+        >>> app_notree = get_app("model", "HKY85")
+        >>> result = app_notree(aln2)
+        >>> result.message
+        'to model more than 3, you must provide a tree'
+
+        A ``NotCompleted`` object is also returned if the model optimization is unsuccessful.
+        (Note that we have deliberately configured the optimiser to raise an exception if
+        it exits because it reached the maximum allowed evaluations.)
+
+        >>> app_limit_act = get_app("model", "GN", opt_args=dict(limit_action="raise",
+        ... max_evaluations=10))
+        >>> result = app_limit_act(aln)
+        >>> print(result.message) # doctest: +NORMALIZE_WHITESPACE
+        Traceback ... FORCED EXIT from optimiser after 10 evaluations
         """
         if tree_func:
             assert callable(tree_func), "tree_func must be callable or None"
@@ -530,7 +647,31 @@ def main(
         self, result: ModelResultType
     ) -> Union[SerialisableType, TabularResultType]:
         """returns Table for all statistics returned by likelihood function
-        get_statistics"""
+        get_statistics
+
+        Examples
+        --------
+
+        Get all parameter estimates from a model fit. The estimates will
+        be stored in a ``dict``-like instance, with keys representing global
+        parameters (if any), parameters specific to branches, and motif
+        probabilities.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> data = {
+        ...     "Human": "ATGCGGCTCGCGGAGGCCGCGCTCGCGGAG",
+        ...     "Mouse": "ATGCCCGGCGCCAAGGCAGCGCTGGCGGAG",
+        ...     "Opossum": "ATGCCAGTGAAAGTGGCGGCGGTGGCTGAG",
+        ... }
+        >>> aln = make_aligned_seqs(data=data, moltype="dna")
+        >>> mod = get_app("model", "HKY85", opt_args=dict(max_evaluatuions=10,
+        ... limit_action="ignore"))
+        >>> result = mod(aln)
+        >>> tabulator = get_app("tabulate_stats")
+        >>> tabulated = tabulator(result)
+        >>> print(tabulated)
+        3x tabular_result('global params': Table, 'edge params': Table, 'motif params': Table)
+        """
         stats = result.lf.get_statistics(with_titles=True, with_motif_probs=True)
         tab = tabular_result(source=result.source)
         for table in stats:
diff --git a/src/cogent3/app/result.py b/src/cogent3/app/result.py
index a2d23cbc8..aacdc75e1 100644
--- a/src/cogent3/app/result.py
+++ b/src/cogent3/app/result.py
@@ -341,6 +341,7 @@ def total_length(self, length_as=None):
     @property
     def tree(self):
         """an annotated tree with 'ENS' set as the branch length
+
         Note
         ----
         In the case of a discrete time process, length is 'paralinear'"""
diff --git a/src/cogent3/app/sample.py b/src/cogent3/app/sample.py
index a335f944e..44e5dbe8b 100644
--- a/src/cogent3/app/sample.py
+++ b/src/cogent3/app/sample.py
@@ -1,12 +1,12 @@
 from collections import defaultdict
-from typing import List, Union
+from typing import List, Optional, Union
 
 from numpy import array
 from numpy import random as np_random
 
 from cogent3.core.alignment import Alignment, ArrayAlignment
 from cogent3.core.genetic_code import get_code
-from cogent3.core.moltype import get_moltype
+from cogent3.core.moltype import MolType, get_moltype
 
 from .composable import NON_COMPOSABLE, NotCompleted, define_app
 from .translate import get_fourfold_degenerate_sets
@@ -16,6 +16,8 @@
 # TODO need a function to filter sequences based on divergence, ala divergent
 # set.
 
+MolTypes = Union[str, MolType]
+
 
 def intersection(groups):
     """returns the intersection of all groups"""
@@ -34,17 +36,63 @@ def union(groups):
 class concat:
     """Creates a concatenated alignment from a series."""
 
-    def __init__(self, join_seq="", intersect=True, moltype=None):
+    def __init__(
+        self, join_seq: str = "", intersect: bool = True, moltype: Optional[str] = None
+    ):
         """
         Parameters
         ----------
-        join_seq : str
+        join_seq
             splice sequences together using this string
-        intersect : bool
+        intersect
             result contains only sequences present in all alignments. If False,
             missings sequences will be replaced by a sequence of '?'.
-        moltype : str
+        moltype
             molecular type, must be either DNA or RNA
+
+        Examples
+        --------
+
+        Create an app to concatenate two alignments
+
+        >>> from cogent3 import app_help, get_app, make_aligned_seqs
+        >>> concat_alns = get_app("concat", moltype="dna")
+
+        Create sample alignments with matching sequence names
+
+        >>> aln1 = make_aligned_seqs({"s1": "AAA", "s2": "CAA", "s3": "AAA"}, moltype="dna")
+        >>> aln2 = make_aligned_seqs({"s1": "GCG", "s2": "GGG", "s3": "GGT"}, moltype="dna")
+
+        Concatenate alignments. By default, sequences without matching names in
+        the corresponding alignment are omitted (intersect=True).
+
+        >>> result = concat_alns([aln1, aln2])
+        >>> print(result.to_pretty(name_order=["s1","s2","s3"]))
+        s1    AAAGCG
+        s2    C...G.
+        s3    ....GT
+
+        Create an app that includes missing sequences across alignments.
+        Missing sequences are replaced by a sequence of "?".
+
+        >>> concat_missing = get_app("concat", moltype="dna", intersect=False)
+        >>> aln3 = make_aligned_seqs({"s4": "GCG", "s5": "GGG"}, moltype="dna")
+        >>> result = concat_missing([aln1, aln3])
+        >>> print(result.to_pretty(name_order=["s1","s2","s3","s4","s5"]))
+        s1    AAA???
+        s2    C.....
+        s3    ......
+        s4    ???GCG
+        s5    ???GGG
+
+        Create an app that delimits concatenated alignments with "N"
+
+        >>> concat_delim = get_app("concat", join_seq="N", moltype="dna")
+        >>> result = concat_delim([aln1, aln2])
+        >>> print(result.to_pretty(name_order=["s1","s2","s3"]))
+        s1    AAANGCG
+        s2    C....G.
+        s3    .....GT
         """
         self._name_callback = {True: intersection}.get(intersect, union)
         self._intersect = intersect
@@ -61,37 +109,40 @@ def main(
         data
             series of alignment instances
         """
-        if len(data) == 0:
-            raise ValueError("no data")
+        if not data:
+            return NotCompleted("ERROR", self, message="no data")
 
         names = []
         for aln in data:
+            if self._moltype is None:
+                self._moltype = aln.moltype
+
             if not isinstance(aln, (ArrayAlignment, Alignment)):
                 raise TypeError(f"{type(aln)} invalid for concat")
             names.append(aln.names)
 
         names = self._name_callback(names)
         collated = defaultdict(list)
-        if self._moltype is None:
-            self._moltype = aln.moltype
 
         for aln in data:
             if self._moltype and aln.moltype != self._moltype:
                 # try converting
-                aln = aln.to_moltype(self.moltype)
+                aln = aln.to_moltype(self._moltype)
 
             if self._intersect:
                 seqs = aln.take_seqs(names).to_dict()
             else:
                 seqs = defaultdict(lambda: "?" * len(aln))
-                seqs.update(aln.to_dict())
+                seqs |= aln.to_dict()
 
             for name in names:
                 collated[name].append(seqs[name])
 
         combined = {n: self._join_seq.join(collated[n]) for n in names}
-        aln = ArrayAlignment(data=combined, moltype=self._moltype)
-        return aln
+        if aln := ArrayAlignment(data=combined, moltype=self._moltype):
+            return aln
+        else:
+            return NotCompleted("FAIL", self, message="result is empty")
 
 
 @define_app
@@ -99,33 +150,87 @@ class omit_degenerates:
     """Excludes alignment columns with degenerate characters. Can accomodate
     reading frame."""
 
-    def __init__(self, moltype=None, gap_is_degen=True, motif_length=1):
+    def __init__(
+        self,
+        moltype: Optional[str] = None,
+        gap_is_degen: bool = True,
+        motif_length: int = 1,
+    ):
         """
         Parameters
         ----------
-        moltype : str
+        moltype
             molecular type, must be either DNA or RNA
-        gap_is_degen : bool
+        gap_is_degen
             include gap character in degenerate character set
-        motif_length : int
+        motif_length
             sequences split into non-overlapping tuples of this size. If a
             tuple contains a degen character at any position the entire tuple
             is excluded
+
+        Examples
+        --------
+        Degenerate IUPAC base symbols represents a site position that can have
+        multiple possible nucleotides. For example, "Y" represents
+        pyrimidines where the site can be either "C" or "T".
+
+        Note: In molecular evolutionary and phylogenetic analyses, the gap
+        character "-" is considered to be any base "N".
+
+        Create sample data with degenerate characters
+
+        >>> from cogent3 import app_help, get_app, make_aligned_seqs
+        >>> aln = make_aligned_seqs({"s1": "ACGA-GACG", "s2": "GATGATGYT"}, moltype="dna")
+
+        Create an app that omits aligned columns containing a degenerate
+        character from an alignment
+
+        >>> app = get_app("omit_degenerates", moltype="dna")
+        >>> result = app(aln)
+        >>> print(result.to_pretty())
+        s1    ACGAGAG
+        s2    GATGTGT
+
+        Create an app which omits degenerate characters, but retains gaps
+
+        >>> app = get_app("omit_degenerates", moltype="dna", gap_is_degen=False)
+        >>> result = app(aln)
+        >>> print(result.to_pretty())
+        s1    ACGA-GAG
+        s2    GATGATGT
+
+        Split sequences into non-overlapping tuples of length 2 and exclude
+        any tuple that contains a degenerate character
+
+        >>> app = get_app("omit_degenerates", moltype="dna", motif_length=2)
+        >>> result = app(aln)
+        >>> print(result.to_pretty())
+        s1    ACGA
+        s2    GATG
+
+        A NotCompleted object (see https://cogent3.org/doc/app/not-completed.html)
+        is returned if the moltype is not specified in the alignment or app
+
+        >>> aln = make_aligned_seqs({"s1": "ACGA-GACG", "s2": "GATGATGYT"})
+        >>> app = get_app("omit_degenerates")
+        >>> result = app(aln)
+        >>> result.message
+        'Traceback...
         """
         if moltype:
             moltype = get_moltype(moltype)
             assert moltype.label.lower() in ("dna", "rna"), "Invalid moltype"
 
-        self.moltype = moltype
+        self._moltype = moltype
         self._allow_gap = not gap_is_degen
         self._motif_length = motif_length
 
     T = Union[SerialisableType, AlignedSeqsType]
 
     def main(self, aln: AlignedSeqsType) -> T:
-        if self.moltype and aln.moltype != self.moltype:
+        if self._moltype and aln.moltype != self._moltype:
             # try converting
-            aln = aln.to_moltype(self.moltype)
+            aln = aln.to_moltype(self._moltype)
 
         return aln.no_degenerates(
             motif_length=self._motif_length, allow_gap=self._allow_gap
@@ -137,32 +242,79 @@ class omit_gap_pos:
     """Excludes gapped alignment columns meeting a threshold. Can accomodate
     reading frame."""
 
-    def __init__(self, allowed_frac=0.99, motif_length=1, moltype=None):
+    def __init__(
+        self,
+        allowed_frac: float = 0.99,
+        motif_length: int = 1,
+        moltype: Optional[str] = None,
+    ):
         """
         Parameters
         ----------
-        allowed_frac : float
+        allowed_frac
             columns with a fraction of gap characters exceeding allowed_frac are
             excluded
-        motif_length : int
+        motif_length
             sequences split into non-overlapping tuples of this size.
-        moltype : str
+        moltype
             molecular type, must be either DNA or RNA
+
+        Examples
+        --------
+
+        Create a sample alignment and an app that excludes highly gapped sites.
+        Sites with over 99% gaps are excluded by default.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "ACGA-GA-CG", "s2": "GATGATG-AT"})
+
+        >>> app = get_app("omit_gap_pos", moltype="dna")
+        >>> result = app(aln)
+        >>> print(result.to_pretty(name_order=["s1", "s2"]))
+        s1    ACGA-GACG
+        s2    GATGATGAT
+
+        Create an app that excludes all aligned sites with over 49% gaps.
+
+        >>> app = get_app("omit_gap_pos", allowed_frac=0.49, moltype="dna")
+        >>> result = app(aln)
+        >>> print(result.to_pretty(name_order=["s1", "s2"]))
+        s1    ACGAGACG
+        s2    GATGTGAT
+
+        To eliminate any codon columns (where a column is a triple of
+        nucleotides) that contain a gap character, we use the motif_length
+        argument.
+
+        >>> app = get_app("omit_gap_pos", allowed_frac=0, motif_length=3, moltype="dna")
+        >>> result = app(aln)
+        >>> print(result.to_pretty(name_order=["s1", "s2"]))
+        s1    ACG
+        s2    GAT
+
+        A NotCompleted object (see https://cogent3.org/doc/app/not-completed.html)
+        is returned if all sites are excluded.
+
+        >>> aln = make_aligned_seqs({"s1": "ACGA------", "s2": "----ATG-AT"})
+        >>> app = get_app("omit_gap_pos", allowed_frac=0, motif_length=3, moltype="dna")
+        >>> result = app(aln)
+        >>> result.message
+        'all columns exceeded gap threshold'
         """
         if moltype:
             moltype = get_moltype(moltype)
             assert moltype.label.lower() in ("dna", "rna"), "Invalid moltype"
 
-        self.moltype = moltype
+        self._moltype = moltype
         self._allowed_frac = allowed_frac
         self._motif_length = motif_length
 
     T = Union[SerialisableType, AlignedSeqsType]
 
     def main(self, aln: AlignedSeqsType) -> T:
-        if self.moltype and aln.moltype != self.moltype:
+        if self._moltype and aln.moltype != self._moltype:
             # try converting
-            aln = aln.to_moltype(self.moltype)
+            aln = aln.to_moltype(self._moltype)
 
         return aln.omit_gap_pos(
             allowed_gap_frac=self._allowed_frac, motif_length=self._motif_length
@@ -177,24 +329,66 @@ class take_codon_positions:
 
     def __init__(
         self,
-        *positions,
-        fourfold_degenerate=False,
-        gc="Standard",
-        moltype="dna",
+        *positions: int,
+        fourfold_degenerate: bool = False,
+        gc: Union[str, int] = "Standard",
+        moltype: str = "dna",
     ):
         """
         Parameters
         ----------
         positions
-            either an integer (1, 2, 3), or a tuple of position numbers,
-            e.g. 3 is third position, (1,2) is first and second codon position
-        fourfold_degenerate : bool
+            either a single integer from (1, 2, 3), or additional keyword
+            arguments of position numbers, e.g. 3 is third position, (1,2)
+            is first and second codon position
+        fourfold_degenerate
             if True, returns third positions from four-fold degenerate codons.
             Overrides positions.
         gc
-            identifier for a genetic code or a genetic code instance
-        moltype : str
+            identifier for a genetic code or a genetic code instance.
+            see https://cogent3.org/doc/cookbook/what_codes.html
+        moltype
             molecular type, must be either DNA or RNA
+
+        Examples
+        --------
+
+        Create a sample alignment and an app that extracts the 3rd codon
+        position from an alignment.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "ACGACGACG", "s2": "GATGATGAT"})
+        >>> take_pos3 = get_app("take_codon_positions", 3, moltype="dna")
+        >>> result = take_pos3(aln)
+        >>> print(result.to_pretty())
+        s1    GGG
+        s2    TTT
+
+        Create an app that extracts the 1st and 2nd codon positions from an
+        alignment.
+
+        >>> take_pos12 = get_app("take_codon_positions", 1, 2, moltype="dna")
+        >>> result = take_pos12(aln)
+        >>> print(result.to_pretty())
+        s1    ACACAC
+        s2    GAGAGA
+
+        Create a sample alignment and an app that returns the 3rd codon
+        positions from four-fold degenerate codons.
+
+        >>> aln_ff = make_aligned_seqs({"s1": "GCAAGCGTTTAT", "s2": "GCTTTTGTCAAT"})
+        >>> take_fourfold = get_app("take_codon_positions", fourfold_degenerate=True, moltype="dna")
+        >>> result = take_fourfold(aln_ff)
+        >>> print(result.to_pretty())
+        s1    AT
+        s2    TC
+
+        A NotCompleted object (see https://cogent3.org/doc/app/not-completed.html)
+        is returned if all sites are excluded.
+
+        >>> result = take_fourfold(aln)
+        >>> result.message
+        'Traceback ...
         """
         assert moltype is not None
         moltype = get_moltype(moltype)
@@ -268,14 +462,41 @@ def main(self, aln: AlignedSeqsType) -> T:
 class take_named_seqs:
     """Selects named sequences from a collection."""
 
-    def __init__(self, *names, negate=False):
+    def __init__(self, *names: str, negate: bool = False):
         """
         Parameters
         ----------
         *names
-            series of sequence names
+            series of sequence names provided as positional arguments
         negate
             if True, excludes the provided names from the result
+
+        Examples
+        --------
+
+        Create a sample alignment and an app that returns the sequences
+        matching the provided names.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({
+        ...     "s1": "GCAAGC",
+        ...     "s2": "GCTTTT",
+        ...     "s3": "GC--GC",
+        ...     "s4": "GCAAGC"
+        ... })
+        >>> app = get_app("take_named_seqs", "s1", "s2")
+        >>> result = app(aln)
+        >>> print(result.to_pretty())
+        s1    GCAAGC
+        s2    ..TTTT
+
+        Create an app that excludes sequences that match the provided names.
+
+        >>> app_negate = get_app("take_named_seqs", "s1", "s2", negate=True)
+        >>> result = app_negate(aln)
+        >>> print(result.to_pretty())
+        s3    GC--GC
+        s4    ..AA..
         """
         self._names = names
         self._negate = negate
@@ -297,23 +518,85 @@ class take_n_seqs:
     """Selects n sequences from a collection. Chooses first n sequences, or
     selects randomly if specified."""
 
-    def __init__(self, number, random=False, seed=None, fixed_choice=True):
+    def __init__(
+        self,
+        number: int,
+        random: bool = False,
+        seed: Optional[int] = None,
+        fixed_choice: bool = True,
+    ):
         """
         Parameters
         ----------
-        number: int
+        number
             number of sequences to sample. If number of sequences in a collectionis < n, returns NotCompleted
             indicating a FAIL.
-        random: bool
+        random
             Whether to choose the sequences randomly.
-        seed: int
+        seed
             Seed for the numpy random number generator.
-        fixed_choice: bool
+        fixed_choice
             sequence names selected from the first alignment are used for all others.
 
         Returns
         -------
         A new sequence collection, or NotCompleted if not insufficient sequences are in the collection.
+
+        Examples
+        --------
+
+        Create a sample alignment and an app that returns ``number=3``
+        sequences. By default, the first 3 sequences from the alignment are
+        returned.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({
+        ...     "s1": "ACGT",
+        ...     "s2": "ACG-",
+        ...     "s3": "ACGN",
+        ...     "s4": "ACGG",
+        ...     "s5": "ACGG"
+        ... })
+        >>> app_first_n = get_app("take_n_seqs", number=3)
+        >>> result = app_first_n(aln)
+        >>> print(result.to_pretty())
+        s1    ACGT
+        s2    ...-
+        s3    ...N
+
+        Using ``random=True`` and ``number=3`` returns 3 random sequences.
+        An optional ``seed`` can be provided to ensure the same sequences
+        are returned each time the app is called.
+
+        >>> app_random_n = get_app("take_n_seqs", number=3, random=True, seed=1)
+        >>> result = app_random_n(aln)
+        >>> print(result.to_pretty())
+        s3    ACGN
+        s2    ...-
+        s5    ...G
+
+        ``fixed_choice=True`` ensures the same sequences are returned when
+        (randomly) sampling sequences across several alignments.
+
+        >>> aln2 = make_aligned_seqs({
+        ...     "s1": "GCGC",
+        ...     "s2": "GCG-",
+        ...     "s3": "GCG-",
+        ...     "s4": "GCGG",
+        ...     "s5": "GCGG",
+        ... })
+        >>> app_fixed = get_app("take_n_seqs", number=3, random=True, fixed_choice=True)
+        >>> result1 = app_fixed(aln).names
+        >>> result2 = app_fixed(aln2).names
+        >>> assert result1 == result2
+
+        When ``number`` exceeds the number of sequences in the alignment, returns a
+        NotCompleted (see https://cogent3.org/doc/app/not-completed.html).
+
+        >>> app_fail = get_app("take_n_seqs", number=6)
+        >>> result = app_fail(aln)
+        >>> result.message
+        'not enough sequences'
         """
         if seed:
             np_random.seed(seed)
@@ -354,21 +637,57 @@ def main(self, data: SeqsCollectionType) -> T:
 class min_length:
     """Filters sequence collections / alignments by length."""
 
-    def __init__(self, length, motif_length=1, subtract_degen=True, moltype=None):
+    def __init__(
+        self,
+        length: int,
+        motif_length: int = 1,
+        subtract_degen: bool = True,
+        moltype: Optional[MolTypes] = None,
+    ):
         """
         Parameters
         ----------
-        length : int
+        length
             only alignments with this length returned, False otherwise
-        motif_length : int
+        motif_length
             length is converted to modulo motif_length
-        subtract_degen : bool
+        subtract_degen
             degenerate characters subtracted from sequence length calculation
         moltype
             molecular type, can be string or instance
+
+        Examples
+        --------
+        Create a sample alignment. Alignments must have a moltype specified
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "ACGGT", "s2": "AC-GT"}, moltype="dna")
+
+        Create the app to filter sequences with a minimum length of 3 sites
+
+        >>> app = get_app("min_length", 3)
+        >>> result = app(aln)
+        >>> len(result)
+        5
+
+        When all sequences are shorter than the min length, returns a
+        NotCompleted (see https://cogent3.org/doc/app/not-completed.html)
+
+        >>> app = get_app("min_length", 7)
+        >>> result = app(aln)
+        >>> result.message
+        '4 < min_length 7'
+
+        If the moltype of the alignment is not specified, returns a NotCompleted
+
+        >>> aln = make_aligned_seqs({"s1": "ACGGT", "s2": "AC-GT"})
+        >>> app = get_app("min_length", 3)
+        >>> result = app(aln)
+        >>> result.message
+        'Traceback...
         """
         if motif_length > 1:
-            length = length // motif_length
+            length //= motif_length
         self._min_length = length
         self._motif_length = motif_length
         self._subtract_degen = subtract_degen
@@ -423,26 +742,75 @@ class fixed_length:
     """Sample an alignment to a fixed length."""
 
     def __init__(
-        self, length, start=0, random=False, seed=None, motif_length=1, moltype=None
+        self,
+        length: int,
+        start: int = 0,
+        random: bool = False,
+        seed: Optional[int] = None,
+        motif_length: int = 1,
+        moltype: Optional[MolTypes] = None,
     ):
         """
         Parameters
         ----------
-        length : int
+        length
             only alignments with this length returned, False otherwise
         start
             integer starting position for truncation, or 'random' in which case
             a random start is chosen (within the possible range returning an
             alignment of the specified length). Overrides  `random`.
-        random : bool
+        random
             random positions for the corresponding tuple are chosen.
-        seed : int
+        seed
             random number seed
-        motif_length : int
+        motif_length
             length of sequence units to consider. If not 1, length and start are
             converted (reduced) if necessary to be modulo motif_length
         moltype
             molecular type, can be string or instance
+
+        Examples
+        --------
+
+        Create a sample alignment and an app that returns the first 4 positions
+        of the alignment.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "GCAAGCGTTTAT", "s2": "GCTTTTGTCAAT"})
+        >>> app_4 = get_app("fixed_length", length=4)
+        >>> result = app_4(aln)
+        >>> print(result.to_pretty())
+        s1    GCAA
+        s2    ..TT
+
+        Return an alignment with ``length=4`` starting from the 2nd position.
+
+        >>> app_4_start2 = get_app("fixed_length", length=4, start=2)
+        >>> result = app_4_start2(aln)
+        >>> print(result.to_pretty())
+        s1    AAGC
+        s2    TTTT
+
+        The start position can be selected at random with ``random=True``. An
+        optional ``seed`` can be provided to ensure the same start position is
+        used when the app is called.
+
+        >>> app_4_random = get_app("fixed_length", length=4, random=True, seed=1)
+        >>> result = app_4_start2(aln)
+        >>> print(result.to_pretty())
+        s1    AAGC
+        s2    TTTT
+
+        Use ``motif_length=3`` to sample two triplets of ``length=6``.
+        Sequences are split into non-overlapping sections of ``motif_length=3``
+        before sampling (i.e. codon positions are preserved).
+
+        >>> aln = make_aligned_seqs({"s1": "GCAAGCGTTTAT", "s2": "GCTTTTGTCAAT"})
+        >>> app_motif3 = get_app("fixed_length", length=6, motif_length=3, random=True, seed=9)
+        >>> result = app_motif3(aln)
+        >>> print(result.to_pretty())
+        s1    AGCTAT
+        s2    TTTA..
         """
         diff = length % motif_length
         if diff != 0:
@@ -516,20 +884,63 @@ def main(self, data: AlignedSeqsType) -> T:
 class omit_bad_seqs:
     """Eliminates sequences from Alignment based on gap fraction, unique gaps."""
 
-    def __init__(self, quantile=None, gap_fraction=1, moltype="dna"):
+    def __init__(
+        self,
+        quantile: Optional[float] = None,
+        gap_fraction: int = 1,
+        moltype: MolTypes = "dna",
+    ):
         """
         Parameters
         ----------
-        quantile : float or None
-            The number of gaps uniquely introduced by a sequence are counted.
-            The value corresponding to quantile is determined and all sequences
-            whose unique gap count is larger than this cutoff are excluded.
-            If None, this condition is not applied.
+        quantile
+            The number of gaps uniquely introduced in an alignment by each
+            sequence are counted. The value corresponding to quantile is
+            determined and all sequences whose unique gap count is larger than
+            this cutoff are excluded. If None, this condition is not applied.
         gap_fraction
             sequences whose proportion of gaps is >= this value are excluded, the
             default excludes sequences that are just gaps.
         moltype
             molecular type, can be string or instance
+
+        Examples
+        --------
+
+        Create a sample alignment and an app to remove sequences based on gap
+        fraction. Use ``gap_fraction=0.5`` to omit sequences that contain 50%
+        or more gaps.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({
+        ...     "s1": "---ACC---TT-",
+        ...     "s2": "---ACC---TT-",
+        ...     "s3": "---ACC---TT-",
+        ...     "s4": "--AACCG-GTT-",
+        ...     "s5": "--AACCGGGTTT",
+        ...     "s6": "AGAACCGGGTT-",
+        ...     "s7": "------------"
+        ... }, moltype="dna")
+        >>> app_frac_05 = get_app("omit_bad_seqs", gap_fraction=0.5)
+        >>> result = app_frac_05(aln)
+        >>> print(result.to_pretty())
+        s4    --AACCG-GTT-
+        s5    .......G...T
+        s6    AG.....G....
+
+        The ``quantile=0.8`` argument omits sequences that introduce gaps in the
+        alignment. In the following example, sequence `s6` is omitted, as it
+        uniquely introduces gaps in the first two positions of the alignment
+        which exceeds the cutoff.
+
+        >>> app = get_app("omit_bad_seqs", quantile=0.8)
+        >>> result = app(aln)
+        >>> print(result.to_pretty())
+        s1    ---ACC---TT-
+        s2    ............
+        s3    ............
+        s4    ..A...G.G...
+        s5    ..A...GGG..T
         """
         if moltype:
             moltype = get_moltype(moltype)
@@ -560,7 +971,13 @@ class omit_duplicated:
     """Removes redundant sequences, recording dropped sequences in
     seqs.info.dropped."""
 
-    def __init__(self, mask_degen=False, choose="longest", seed=None, moltype=None):
+    def __init__(
+        self,
+        mask_degen: bool = False,
+        choose: str = "longest",
+        seed: Optional[int] = None,
+        moltype: Optional[MolTypes] = None,
+    ):
         """
         Parameters
         ----------
@@ -570,10 +987,65 @@ def __init__(self, mask_degen=False, choose="longest", seed=None, moltype=None):
             choose a representative from sets of duplicated sequences.
             Valid values are None (all members of a duplicated set are excluded),
             'longest', 'random'.
-        seed : int
+        seed
             set random number seed. Only applied of choose=='random'
         moltype
             molecular type, can be string or instance
+
+        Examples
+        --------
+        Removes redundant sequences from a sequence collection (aligned or
+        unaligned).
+
+        Create sample data with duplicated sequences.
+
+        >>> from cogent3 import get_app, make_aligned_seqs
+        >>> data = {
+        ... "a": "ACGT",
+        ... "b": "ACG-",  # identical to 'a' except has a gap
+        ... "c": "ACGG",  # duplicate
+        ... "d": "ACGG",  # duplicate
+        ... "e": "AGTC"   # unique
+        ... }
+
+        Create an app that selects a representative of omits duplicate sequences.
+        Setting ``choose="longest"`` selects the duplicated sequence with the least
+        number of gaps and ambiguous characters. In this case, only one of 'c' and
+        'd' will be retained.
+
+        >>> seqs = make_aligned_seqs(data, moltype="DNA")
+        >>> app = get_app("omit_duplicated", moltype="dna", choose="longest")
+        >>> result = app(seqs)
+        >>> print(result.to_pretty())
+        a    ACGT
+        b    ...-
+        d    ...G
+        e    .GTC
+
+        Setting ``choose=None`` means only unique sequences are retained.
+
+        >>> app = get_app("omit_duplicated", moltype="dna", choose=None)
+        >>> result = app(seqs)
+        >>> print(result.to_pretty())
+        a    ACGT
+        b    ...-
+        e    .GTC
+
+        Use the ``mask_degen`` argument to specify how to treat matches between
+        sequences with degenerate characters. We create sample data first that
+        has a DNA ambiguity code.
+
+        >>> data = make_aligned_seqs({
+        ... "s1": "ATCG",
+        ... "s2": "ATYG",  # matches s1 with ambiguity
+        ... "s3": "GGTA",
+        ... },
+        ... moltype="DNA")
+        >>> app_dna = get_app("omit_duplicated", mask_degen=True, choose="longest", moltype="DNA")
+        >>> result = app_dna(data)
+        >>> print(result.to_pretty())
+        s1    ATCG
+        s3    GGTA
         """
         assert not choose or choose in "longestrandom"
         if moltype:
@@ -640,18 +1112,46 @@ def main(self, seqs: SeqsCollectionType) -> T:
 class trim_stop_codons:
     """Removes terminal stop codons."""
 
-    def __init__(self, gc=1):
+    def __init__(self, gc: Union[str, int] = 1):
         """
         Parameters
         ----------
         gc
             identifier for a genetic code or a genetic code instance, defaults
-            to standard genetic code
+            to standard genetic code. See https://cogent3.org/doc/cookbook/what_codes.html
 
         Returns
         -------
         A new sequence collection, or False if not all the named sequences are
         in the collection.
+
+        Examples
+        --------
+
+        Removes trailing (terminal) stop codons from sequences in a sequence
+        collection or alignment.
+
+        Create a sample unaligned sequence data and an app to remove trailing
+        stop codons.
+
+        >>> from cogent3 import make_unaligned_seqs, make_aligned_seqs, get_app
+        >>> ualn = make_unaligned_seqs(data={"s1": "AAATTTCCC", "s2": "AAATTTTAA"}, moltype="dna")
+        >>> app = get_app("trim_stop_codons")
+        >>> result = app(ualn)
+        >>> print(result.to_dict())
+        {'s1': 'AAATTTCCC', 's2': 'AAATTT'}
+
+        Remove trailing stop codons from aligned sequences, using the genetic
+        code for "Vertebrate Mitochondrial".
+
+        For a list of all genetic codes, see
+        https://cogent3.org/doc/cookbook/what_codes.html.
+
+        >>> aln = make_aligned_seqs(data={"s1": "AAATTTCCC", "s2": "AAATTTTAA"}, moltype="dna")
+        >>> app = get_app("trim_stop_codons", gc=2)
+        >>> result = app(aln)
+        >>> print(result.to_dict())
+        {'s1': 'AAATTTCCC', 's2': 'AAATTT---'}
         """
         self._gc = gc
 
diff --git a/src/cogent3/app/sqlite_data_store.py b/src/cogent3/app/sqlite_data_store.py
index 3215c221d..28ddaef12 100644
--- a/src/cogent3/app/sqlite_data_store.py
+++ b/src/cogent3/app/sqlite_data_store.py
@@ -32,6 +32,24 @@
 _mem_pattern = re.compile(r"^\s*[:]{0,1}memory[:]{0,1}\s*$")
 NoneType = type(None)
 
+# dealing with python3.12 deprecation of datetime objects and their sqlite3 handling
+
+
+def _datetime_to_iso(timestamp: datetime.datetime) -> str:
+    """timestamp in ISO 8601 format"""
+    return timestamp.isoformat()
+
+
+sqlite3.register_adapter(datetime.datetime, _datetime_to_iso)
+
+
+def _datetime_from_iso(data: bytes) -> datetime.datetime:
+    """timestamp from ISO 8601 format"""
+    return datetime.datetime.fromisoformat(data.decode())
+
+
+sqlite3.register_converter("timestamp", _datetime_from_iso)
+
 
 # create db
 def open_sqlite_db_rw(path: Union[str, Path]):
@@ -155,7 +173,7 @@ def db(self):
         return self._db
 
     def _init_log(self):
-        timestamp = datetime.datetime.now()
+        timestamp = datetime.datetime.now(tz=datetime.timezone.utc)
         self.db.execute(f"INSERT INTO {_LOG_TABLE}(date) VALUES (?)", (timestamp,))
         self._log_id = self._db.execute(
             f"SELECT log_id FROM {_LOG_TABLE} where date = ?", (timestamp,)
diff --git a/src/cogent3/app/translate.py b/src/cogent3/app/translate.py
index d401ae405..27af4b9dd 100644
--- a/src/cogent3/app/translate.py
+++ b/src/cogent3/app/translate.py
@@ -1,15 +1,24 @@
 from collections import defaultdict
-from typing import Union
+from typing import Optional, Union
 
 from cogent3.core.alignment import SequenceCollection
-from cogent3.core.genetic_code import get_code
-from cogent3.core.moltype import get_moltype
+from cogent3.core.genetic_code import GeneticCode, get_code
+from cogent3.core.moltype import Alphabet, MolType, get_moltype
 
 from .composable import NotCompleted, define_app
 from .typing import SeqsCollectionType, SerialisableType
 
 
-def best_frame(seq, gc=1, allow_rc=False, require_stop=False):
+GeneticCodeTypes = Union[str, int, GeneticCode]
+MolTypes = Union[str, MolType]
+
+
+def best_frame(
+    seq: SeqsCollectionType,
+    gc: GeneticCodeTypes = 1,
+    allow_rc: bool = False,
+    require_stop: bool = False,
+):
     """returns reading frame start that has either no stops or a single
     terminal stop codon
 
@@ -21,7 +30,7 @@ def best_frame(seq, gc=1, allow_rc=False, require_stop=False):
         genetic code ID, name or instance
     allow_rc
         If False, forward strand considered only. If True, and
-          best frame on rc, it will be negative
+        best frame on rc, it will be negative
     require_stop
         a terminal stop must be present
 
@@ -73,16 +82,21 @@ def best_frame(seq, gc=1, allow_rc=False, require_stop=False):
     return frame
 
 
-def translate_frames(seq, moltype=None, gc=1, allow_rc=False):
+def translate_frames(
+    seq: SeqsCollectionType,
+    moltype: Optional[MolTypes] = None,
+    gc: GeneticCodeTypes = 1,
+    allow_rc: bool = False,
+):
     """translates a nucleic acid sequence
 
     Parameters
     ----------
     moltype
-        molecular type, must be either DNA or RNA
+        molecular type, must be either DNA or RNA. Can be string or instance
     gc
         identifer for a genetic code or a genetic code instance
-    allow_rc : bool
+    allow_rc
         includes frames sequence reverse complement
 
     Returns
@@ -102,7 +116,9 @@ def translate_frames(seq, moltype=None, gc=1, allow_rc=False):
     return translations
 
 
-def get_fourfold_degenerate_sets(gc, alphabet=None, as_indices=True):
+def get_fourfold_degenerate_sets(
+    gc: GeneticCodeTypes, alphabet: Optional[Alphabet] = None, as_indices: bool = True
+):
     """returns set() of codons that are 4-fold degenerate for genetic code gc
 
     Parameters
@@ -145,24 +161,55 @@ class select_translatable:
     frame. Sequences are truncated to modulo 3. seqs.info has a
     translation_errors entry."""
 
-    def __init__(self, moltype="dna", gc=1, allow_rc=False, trim_terminal_stop=True):
+    def __init__(
+        self,
+        moltype: MolTypes = "dna",
+        gc: GeneticCodeTypes = 1,
+        allow_rc: bool = False,
+        trim_terminal_stop: bool = True,
+    ):
         """
         Parameters
         ----------
-        moltype : str
-            molecular type, must be either DNA or RNA
+        moltype
+            molecular type, must be either DNA or RNA. Can be string or
+            instance
         gc
-            identifier for a genetic code or a genetic code instance
-        allow_rc : bool
+            identifier for a genetic code or a genetic code instance.
+            see https://cogent3.org/doc/cookbook/what_codes.html
+        allow_rc
             If False, forward strand considered only. If True, and
-              best frame on rc, it will be negative
-        trim_terminal_stop : bool
+            best frame on rc, it will be negative
+        trim_terminal_stop
             exclude terminal stop codon from seqs
 
         Returns
         -------
         A sequence collection. Sequences that could not be translated
         are excluded.
+
+        Examples
+        --------
+
+        Create a sample sequence collection and an app that returns the sequences
+        which are translatable.
+
+        >>> from cogent3 import make_unaligned_seqs, get_app
+        >>> aln = make_unaligned_seqs({
+        ...     "s1": "AATATAAATGCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTTCATAAAGTCATA",
+        ...     "s1_rc": "TATGACTTTATGAAGTAATAAACTGCTGTTCTCATGCTGTAATGAGCTGGCATTTATATT"
+        ... })
+        >>> app = get_app("select_translatable")
+        >>> result = app(aln)
+        >>> print(result.to_dict())
+        {'s1': 'AATATAAATGCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTTCATAAAGTCATA'}
+
+        Use ``allow_rc=True`` to consider the reading frame for reverse strands.
+
+        >>> app_rc = get_app("select_translatable", allow_rc=True)
+        >>> result = app_rc(aln)
+        >>> print(result.to_dict())
+        {'s1': 'AATATAAATGCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTTCATAAAGTCATA', 's1_rc': 'AATATAAATGCCAGCTCATTACAGCATGAGAACAGCAGTTTATTACTTCATAAAGTCATA'}
         """
         moltype = get_moltype(moltype)
         assert moltype.label.lower() in ("dna", "rna"), "Invalid moltype"
@@ -219,21 +266,44 @@ class translate_seqs:
     """Translates nucleic acid sequences into protein sequences, assumes in
     correct reading frame."""
 
-    def __init__(self, moltype="dna", gc=1, allow_rc=False, trim_terminal_stop=True):
+    def __init__(
+        self,
+        moltype: MolTypes = "dna",
+        gc: GeneticCodeTypes = 1,
+        allow_rc: bool = False,
+        trim_terminal_stop: bool = True,
+    ):
         """
         Parameters
         ----------
-        moltype : str
-            molecular type, must be either DNA or RNA
+        moltype
+            molecular type, must be either DNA or RNA. Can be string or
+            instance
         gc
-            identifier for a genetic code or a genetic code instance
-        trim_terminal_stop : bool
+            identifier for a genetic code or a genetic code instance.
+            see https://cogent3.org/doc/cookbook/what_codes.html
+        trim_terminal_stop
             exclude terminal stop codon from seqs
 
         Returns
         -------
         A sequence collection. Sequences that could not be translated
         are excluded.
+
+        Examples
+        --------
+
+        Create a sample sequence collection and an app that translates nucleic
+        acid sequences into protein sequences. By default, sequences that end
+        with a stop codon are excluded with ``trim_terminal_stop=True``.
+
+        >>> from cogent3 import make_aligned_seqs, get_app
+        >>> aln = make_aligned_seqs({"s1": "ATGAGG", "s2": "ATGTAA"})
+        >>> app_translate = get_app("translate_seqs", trim_terminal_stop=True)
+        >>> result = app_translate(aln)
+        >>> print(result.to_pretty())
+        s1    MR
+        s2    .-
         """
         moltype = get_moltype(moltype)
         assert moltype.label.lower() in ("dna", "rna"), "Invalid moltype"
diff --git a/src/cogent3/app/typing.py b/src/cogent3/app/typing.py
index fa5c42712..0147eec6c 100644
--- a/src/cogent3/app/typing.py
+++ b/src/cogent3/app/typing.py
@@ -1,16 +1,23 @@
 """defined type hints for app composability"""
+
 # todo write more extensive docstring explaining limited use of these types
 from __future__ import annotations
 
 import inspect
 import re
+import sys
 
-from typing import ForwardRef, Iterable, TypeVar, Union
+from typing import ForwardRef, TypeVar, Union
 
 from typing_extensions import get_args, get_origin
 
-from cogent3.util import warning as c3warn
 
+if sys.version_info.minor >= 10:
+    from types import UnionType
+
+    NESTED_HINTS = (Union, UnionType, list, tuple, set)
+else:
+    NESTED_HINTS = (Union, list, tuple, set)
 
 AlignedSeqsType = TypeVar("AlignedSeqsType", "Alignment", "ArrayAlignment")
 UnalignedSeqsType = TypeVar("UnalignedSeqsType", bound="SequenceCollection")
@@ -44,7 +51,6 @@
     TabularResultType,
 ]
 
-# todo when move to python 3.8 define protocols for IdentifierType and SerialisableType
 IdentifierType = TypeVar("IdentifierType")
 
 
@@ -59,22 +65,8 @@ def _is_type(text):
 
 _all_types = {n: t for n, t in locals().items() if _is_type(n)}
 
-# the following constants are deprecated
-ALIGNED_TYPE = "aligned"
-IDENTIFIER_TYPE = "identifier"
-PAIRWISE_DISTANCE_TYPE = "pairwise_distances"
-SEQUENCE_TYPE = "sequences"
-SERIALISABLE_TYPE = "serialisable"
-TABULAR_TYPE = "tabular"
-TREE_TYPE = "tree"
-BOOTSTRAP_RESULT_TYPE = "bootstrap_result"
-HYPOTHESIS_RESULT_TYPE = "hypothesis_result"
-MODEL_RESULT_TYPE = "model_result"
-RESULT_TYPE = "result"
-TABULAR_RESULT_TYPE = "tabular_result"
-
-
-def get_constraint_names(*hints) -> set[str, ...]:
+
+def get_constraint_names(*hints) -> set[str | type]:
     """returns the set of named constraints of a type hint"""
     all_hints = set()
     for hint in hints:
@@ -92,7 +84,7 @@ def get_constraint_names(*hints) -> set[str, ...]:
             all_hints.update(hint.__constraints__)
             continue
 
-        if get_origin(hint) in (Union, list, tuple, set):
+        if get_origin(hint) in NESTED_HINTS:
             all_hints.update(get_constraint_names(*get_args(hint)))
 
         if type(hint) == type:
@@ -102,8 +94,7 @@ def get_constraint_names(*hints) -> set[str, ...]:
         elif type(hint) == str:
             all_hints.add(hint)
 
-    all_hints = {h.__forward_arg__ if type(h) == ForwardRef else h for h in all_hints}
-    return all_hints
+    return {h.__forward_arg__ if type(h) == ForwardRef else h for h in all_hints}
 
 
 def type_tree(hint, depth=0) -> tuple:
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index ae3337312..d2f909275 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -18,6 +18,7 @@
     passed in a stream of two-item label, sequence pairs. However, this can
     cause confusion when testing.
 """
+
 from __future__ import annotations
 
 import functools
@@ -29,10 +30,18 @@
 
 from collections import Counter, defaultdict
 from copy import deepcopy
-from functools import total_ordering
+from functools import singledispatchmethod, total_ordering
 from itertools import combinations
-from types import GeneratorType
-from typing import Any, Iterable, Iterator, List, Optional, Tuple, Union
+from typing import (
+    Any,
+    Iterable,
+    Iterator,
+    List,
+    Mapping,
+    Optional,
+    Tuple,
+    Union,
+)
 
 import numpy
 
@@ -57,7 +66,7 @@
 import cogent3  # will use to get at cogent3.parse.fasta.MinimalFastaParser,
 
 from cogent3._version import __version__
-from cogent3.core.annotation import Feature, Map
+from cogent3.core.annotation import Feature
 from cogent3.core.annotation_db import (
     BasicAnnotationDb,
     FeatureDataType,
@@ -67,6 +76,7 @@
 )
 from cogent3.core.genetic_code import get_code
 from cogent3.core.info import Info as InfoClass
+from cogent3.core.location import FeatureMap, IndelMap
 from cogent3.core.profile import PSSM, MotifCountsArray
 from cogent3.core.sequence import ArraySequence, Sequence, frac_same
 # which is a circular import otherwise.
@@ -75,7 +85,6 @@
 from cogent3.format.nexus import nexus_from_alignment
 from cogent3.format.phylip import alignment_to_phylip
 from cogent3.maths.stats.number import CategoryCounter
-from cogent3.parse.gff import gff_parser
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
 from cogent3.util.dict_array import DictArrayTemplate
@@ -250,113 +259,6 @@ def coerce_to_string(s):
         return "".join(map(str, s))
 
 
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_array(a, alphabet=None):  # pragma: no cover
-    """SequenceCollection from array of pos x seq: names are integers.
-
-    This is an InputHandler for SequenceCollection. It converts an arbitrary
-    array of numbers into Sequence objects, and leaves the sequences unlabeled.
-    """
-    return list(transpose(a)), None
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_array_seqs(seqs, alphabet=None):  # pragma: no cover
-    """Alignment from ArraySequence objects: seqs -> array, names from seqs.
-
-    This is an InputHandler for SequenceCollection. It converts a list of
-    Sequence objects with _data and name properties into a SequenceCollection
-    that uses those sequences.
-    """
-    return seqs, [s.name for s in seqs]
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_generic(seqs, alphabet=None):  # pragma: no cover
-    """returns seqs, names"""
-    names = []
-    for s in seqs:
-        if hasattr(s, "name"):
-            names.append(s.name)
-        else:
-            names.append(None)
-    return seqs, names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_fasta(seqs, alphabet=None):  # pragma: no cover
-    """SequenceCollection from FASTA-format string or lines.
-
-    This is an InputHandler for SequenceCollection. It converts a FASTA-format
-    string or collection of lines into a SequenceCollection object, preserving
-    order..
-    """
-    if isinstance(seqs, str):
-        seqs = seqs.splitlines()
-    names, seqs = list(zip(*list(cogent3.parse.fasta.MinimalFastaParser(seqs))))
-    return list(seqs), list(names)
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_dict(seqs, alphabet=None):  # pragma: no cover
-    """SequenceCollection from dict of {label:seq_as_str}.
-
-    This is an InputHandler for SequenceCollection. It converts a dict in
-    which the keys are the names and the values are the sequences
-    (sequence only, no whitespace or other formatting) into a
-    SequenceCollection. Because the dict doesn't preserve order, the result
-    will not necessarily be in alphabetical order."""
-    names, seqs = list(map(list, list(zip(*list(seqs.items())))))
-    seqs = [bytes_to_string(s) for s in seqs]
-    return seqs, names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_kv_pairs(seqs, alphabet=None):  # pragma: no cover
-    """SequenceCollection from list of (key, val) pairs.
-
-    This is an InputHandler for SequenceCollection. It converts a dict in
-    which the keys are the names and the values are the sequences
-    (sequence only, no whitespace or other formatting) into a
-    SequenceCollection. Because the dict doesn't preserve order, the result
-    will be in arbitrary order."""
-    names, seqs = list(map(list, list(zip(*seqs))))
-    seqs = [bytes_to_string(s) for s in seqs]
-    return seqs, names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_aln(seqs, alphabet=None):  # pragma: no cover
-    """SequenceCollection from existing SequenceCollection object: copies data.
-
-    This is relatively inefficient: you should really use the copy() method
-    instead, which duplicates the internal data structures.
-    """
-    return seqs.seqs, seqs.names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def seqs_from_empty(obj, *args, **kwargs):  # pragma: no cover
-    """SequenceCollection from empty data: raise exception."""
-    raise ValueError("Cannot create empty SequenceCollection.")
-
-
 @functools.singledispatch
 def merged_db_collection(seqs) -> SupportsFeatures:
     """return one AnnotationDb's
@@ -475,16 +377,6 @@ class _SequenceCollectionBase:
 
     is_array = set(["array", "array_seqs"])
 
-    @c3warn.deprecated_args(
-        "2024.3",
-        reason="simplifying API",
-        discontinued=(
-            "remove_duplicate_names",
-            "alphabet",
-            "suppress_named_seqs",
-            "force_same_data",
-        ),
-    )
     def __init__(
         self,
         data,
@@ -555,6 +447,11 @@ def __init__(
         if isinstance(data, (typing.Generator, typing.Iterator)):
             data = tuple(data)
 
+        if len(data) == 0:
+            raise ValueError(
+                f"{self.__class__.__name__} must take at least one sequence."
+            )
+
         if moltype is None:
             moltype = _moltype_from_data(data)
         moltype = cogent3.get_moltype(moltype or self.moltype)
@@ -638,9 +535,12 @@ def deepcopy(self, sliced: bool = True):
             annotations.
         """
         if isinstance(self, Alignment):
-            reversed = self.seqs[0].map.reverse
+            *_, strand = self.seqs[0].data.parent_coordinates()
+
         else:
-            reversed = self.seqs[0]._seq.reverse
+            *_, strand = self.seqs[0].parent_coordinates()
+
+        reversed = strand == "-"
         new_seqs = dict()
         db = None if reversed and sliced else deepcopy(self.annotation_db)
         for seq in self.seqs:
@@ -707,7 +607,7 @@ def seqs(self):
 
     # access as attribute if using default order.
 
-    def take_seqs(self, seqs, negate=False, **kwargs):
+    def take_seqs(self, seqs: Union[str, typing.Sequence[str]], negate=False, **kwargs):
         """Returns new Alignment containing only specified seqs.
 
         Note that the seqs in the new alignment will be references to the
@@ -983,17 +883,20 @@ def to_json(self):
         """returns json formatted string"""
         return json.dumps(self.to_rich_dict())
 
-    def to_fasta(self):
-        """Return alignment in Fasta format
+    def to_fasta(self, block_size: int = 60) -> str:
+        """Return alignment in Fasta format.
 
         Parameters
         ----------
-        make_seqlabel
-            callback function that takes the seq object and
-            returns a label str
+        block_size
+            the sequence length to write to each line,
+            by default 60
 
+        Returns
+        -------
+        The Fasta formatted alignment.
         """
-        return alignment_to_fasta(self.to_dict())
+        return alignment_to_fasta(self.to_dict(), block_size=block_size)
 
     def to_nexus(self, seq_type, wrap=50):
         """
@@ -1199,10 +1102,15 @@ def get_seq(self, seqname):
         """Return a sequence object for the specified seqname."""
         return self.named_seqs[seqname]
 
-    def to_dict(self):
-        """Returns the alignment as dict of names -> strings.
+    def to_dict(self) -> dict[str, str]:
+        """Returns the alignment as a dict of sequence names -> strings.
+
+        Note the mapping goes to strings, not Sequence objects.
 
-        Note: returns strings, NOT Sequence objects.
+        Returns
+        -------
+        a dict mapping sequence names to a string representation of
+        their sequences.
         """
         align_dict = {}
 
@@ -1314,7 +1222,7 @@ def get_lengths(self, include_ambiguity=False, allow_gap=False):
         include_ambiguity
             if True, motifs containing ambiguous characters
             from the seq moltype are included. No expansion of those is attempted.
-        allow_gaps
+        allow_gap
             if True, motifs containing a gap character are included.
 
         """
@@ -1329,6 +1237,7 @@ def counts_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=False,
+        warn=False,
     ):
         """counts of motifs per sequence
 
@@ -1341,6 +1250,9 @@ def counts_per_seq(
             from the seq moltype are included. No expansion of those is attempted.
         allow_gap
             if True, motifs containing a gap character are included.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
 
         Returns
         -------
@@ -1362,6 +1274,7 @@ def counts_per_seq(
                 include_ambiguity=include_ambiguity,
                 allow_gap=allow_gap,
                 exclude_unobserved=exclude_unobserved,
+                warn=warn,
             )
             motifs.update(c.keys())
             counts.append(c)
@@ -1386,7 +1299,7 @@ def counts(
         include_ambiguity
             if True, motifs containing ambiguous characters
             from the seq moltype are included. No expansion of those is attempted.
-        allow_gaps
+        allow_gap
             if True, motifs containing a gap character are included.
         exclude_unobserved
             if True, unobserved motif combinations are excluded.
@@ -1594,41 +1507,42 @@ def strand_symmetry(self, motif_length=1):
 
     def dotplot(
         self,
-        name1=None,
-        name2=None,
-        window=20,
-        threshold=None,
-        k=None,
-        min_gap=0,
-        width=500,
-        title=None,
-        rc=False,
-        show_progress=False,
+        name1: Optional[str] = None,
+        name2: Optional[str] = None,
+        window: int = 20,
+        threshold: Optional[int] = None,
+        k: Optional[int] = None,
+        min_gap: int = 0,
+        width: int = 500,
+        title: Optional[str] = None,
+        rc: bool = False,
+        show_progress: bool = False,
     ):
         """make a dotplot between specified sequences. Random sequences
         chosen if names not provided.
 
         Parameters
         ----------
-        name1, name2 : str or None
-            names of sequences. If one is not provided, a random choice is made
-        window : int
-            k-mer size for comparison between sequences
-        threshold : int
+        name1, name2
+            names of sequences. If not provided, a random choice is made
+        window
+            segment size for comparison between sequences
+        threshold
             windows where the sequences are identical >= threshold are a match
-        k : int
+        k
             size of k-mer to break sequences into. Larger values increase
-            speed but reduce resolution. If not specified, is computed as the
-            maximum of (window-threshold), (window % k) * k <= threshold.
-        min_gap : int
+            speed but reduce resolution. If not specified, and
+            window == threshold, then k is set to window. Otherwise, it is
+            computed as the maximum of {threshold // (window - threshold), 5}.
+        min_gap
             permitted gap for joining adjacent line segments, default is no gap
             joining
-        width : int
+        width
             figure width. Figure height is computed based on the ratio of
             len(seq1) / len(seq2)
         title
             title for the plot
-        rc : bool or None
+        rc
             include dotplot of reverse compliment also. Only applies to Nucleic
             acids moltypes
 
@@ -1727,6 +1641,7 @@ def rename_seqs(self, renamer):
             new[new_name] = new_seq
         result = self.__class__(data=new, info=self.info, moltype=self.moltype)
         result.info.name_map = name_map
+        result.annotation_db = self.annotation_db
         return result
 
     @UI.display_wrap
@@ -1838,7 +1753,7 @@ def probs_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=False,
-        alert=False,
+        warn=False,
     ):
         """return MotifFreqsArray per sequence"""
 
@@ -1847,11 +1762,9 @@ def probs_per_seq(
             include_ambiguity=include_ambiguity,
             allow_gap=allow_gap,
             exclude_unobserved=exclude_unobserved,
+            warn=warn,
         )
-        if counts is None:
-            return None
-
-        return counts.to_freq_array()
+        return None if counts is None else counts.to_freq_array()
 
     def entropy_per_seq(
         self,
@@ -1859,7 +1772,7 @@ def entropy_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=True,
-        alert=False,
+        warn=False,
     ):
         """Returns the Shannon entropy per sequence.
 
@@ -1874,6 +1787,9 @@ def entropy_per_seq(
             if True, motifs containing a gap character are included.
         exclude_unobserved: bool
             if True, unobserved motif combinations are excluded.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
 
         Notes
         -----
@@ -1885,7 +1801,7 @@ def entropy_per_seq(
             include_ambiguity=include_ambiguity,
             allow_gap=allow_gap,
             exclude_unobserved=exclude_unobserved,
-            alert=alert,
+            warn=warn,
         )
         if probs is None:
             return None
@@ -1949,10 +1865,11 @@ def annotate_from_gff(
 
         Parameters
         ----------
-        f : path to gff annotation file.
-        seq_name : names of seqs to be annotated.
-        does not support setting offset, set offset directly on sequences with seq.annotation_offset = offset
-
+        f
+            path to gff annotation file.
+        seq_name
+            names of seqs to be annotated. Does not support setting offset,
+            set offset directly on sequences with seq.annotation_offset = offset
         """
         if isinstance(self.annotation_db, GenbankAnnotationDb):
             raise ValueError("GenbankAnnotationDb already attached")
@@ -2064,11 +1981,10 @@ def get_features(
         if not self.annotation_db:
             return None
 
-        seqids = [seqid] if isinstance(seqid, str) else seqid
-        if seqids is None:
-            seqids = self.names
-
-        if seqid and not set(seqids) & set(self.names):
+        seqid_to_seqname = {seq.parent_coordinates()[0]: seq.name for seq in self.seqs}
+        if seqid and (
+            seqid not in seqid_to_seqname and seqid not in seqid_to_seqname.values()
+        ):
             raise ValueError(f"unknown {seqid=}")
 
         for feature in self.annotation_db.get_features_matching(
@@ -2078,7 +1994,8 @@ def get_features(
             on_alignment=False,
             allow_partial=allow_partial,
         ):
-            seq = self.named_seqs[feature["seqid"]]
+            seqname = seqid_to_seqname[feature["seqid"]]
+            seq = self.named_seqs[seqname]
             if offset := seq.annotation_offset:
                 feature["spans"] = (array(feature["spans"]) - offset).tolist()
             yield seq.make_feature(feature, self)
@@ -2127,17 +2044,220 @@ def distance_matrix(self, calc="pdist"):
 
         return dist_calc_app(self)
 
+    def __repr__(self):
+        seqs = []
+        limit = 10
+        delimiter = ""
+
+        repr_seq_names = [min(self.names, key=lambda name: len(self.named_seqs[name]))]
+        if len(self.names) > 1:
+            # In case of a tie, min and max return first.
+            # reversed ensures if all seqs are of same length, different seqs are returned
+            repr_seq_names.append(
+                max(reversed(self.names), key=lambda name: len(self.named_seqs[name]))
+            )
+
+        for name in repr_seq_names:
+            elts = list(str(self.named_seqs[name])[: limit + 1])
+            if len(elts) > limit:
+                elts[-1] = "..."
+            seqs.append(f"{name}[{delimiter.join(elts)}]")
+
+        if len(self.names) > 2:
+            seqs.insert(1, "...")
+
+        seqs = ", ".join(seqs)
+
+        return f"{len(self.names)}x ({seqs}) {self.moltype.get_type()} seqcollection"
+
+    def _repr_html_(self) -> str:
+        settings = self._repr_policy.copy()
+        env_vals = get_setting_from_environ(
+            "COGENT3_ALIGNMENT_REPR_POLICY",
+            dict(num_seqs=int, num_pos=int, wrap=int),
+        )
+        settings.update(env_vals)
+        return self.to_html(
+            name_order=self.names[: settings["num_seqs"]],
+            limit=settings["num_pos"],
+            wrap=settings["wrap"],
+        )
+
+    def to_html(
+        self,
+        name_order: Optional[typing.Sequence[str]] = None,
+        wrap: int = 60,
+        limit: Optional[int] = None,
+        colors: Optional[Mapping[str, str]] = None,
+        font_size: int = 12,
+        font_family: str = "Lucida Console",
+    ) -> str:
+        """returns html with embedded styles for sequence colouring
+
+        Parameters
+        ----------
+        name_order
+            order of names for display.
+        wrap
+            number of alignment columns per row
+        limit
+            truncate alignment to this length
+        colors
+            {character
+            moltype.
+        font_size
+            in points. Affects labels and sequence and line spacing
+            (proportional to value)
+        font_family
+            string denoting font family
+
+        Examples
+        ---------
+
+        In a jupyter notebook, this code is used to provide the representation.
+
+        .. code-block:: python
+
+            seq_col # is rendered by jupyter
+
+        You can directly use the result for display in a notebook as
+
+        .. code-block:: python
+
+            from IPython.core.display import HTML
+            HTML(seq_col.to_html())
+        """
+        css, styles = self.moltype.get_css_style(
+            colors=colors, font_size=font_size, font_family=font_family
+        )
+
+        # Gather stats about the sequence lengths
+        seq_lens = sorted([len(seq) for seq in self.seqs])
+        if seq_lens:
+            min_seq_len = seq_lens[0]
+            max_seq_len = seq_lens[-1]
+
+            # Find median
+            if len(seq_lens) % 2 == 0:
+                total = (
+                    seq_lens[(len(seq_lens) - 2) // 2] + seq_lens[len(seq_lens) // 2]
+                )
+                med_seq_len = total // 2 if total % 2 == 0 else round(total / 2, 1)
+            else:
+                med_seq_len = seq_lens[(len(seq_lens) - 1) // 2]
+        else:
+            min_seq_len = med_seq_len = max_seq_len = 0
+
+        if name_order:
+            selected = self.take_seqs(name_order)
+        else:
+            name_order = list(self.names)
+            selected = self
+
+        # Stylise each character in each sequence
+        gaps = "".join(selected.moltype.gaps)
+        template = '<span class="%s">%%s</span>'
+        styled_seqs = defaultdict(list)
+        max_truncated_len = 0
+        for name in name_order:
+            sequence = str(self.named_seqs[name])[:limit]
+            seq_len = len(sequence)
+            max_truncated_len = max(seq_len, max_truncated_len)
+            start_gap = re.search(f"^[{gaps}]+", sequence)
+            end_gap = re.search(f"[{gaps}]+$", sequence)
+            start = 0 if start_gap is None else start_gap.end()
+            end = seq_len if end_gap is None else end_gap.start()
+
+            seq = []
+            for i, char in enumerate(sequence):
+                if i < start or i >= end:
+                    style = f"terminal_ambig_{self.moltype.label}"
+                else:
+                    style = styles[char]
+                s = template % style
+                s = s % char
+                seq.append(s)
+
+            styled_seqs[name] = seq
+
+        # Ensure all sublists are of same length
+        for name in styled_seqs:
+            if len(styled_seqs[name]) < max_truncated_len:
+                styled_seqs[name].extend(
+                    [""] * (max_truncated_len - len(styled_seqs[name]))
+                )
+
+        # Make html table
+        seqs = array([styled_seqs[n] for n in name_order], dtype="O")
+        table = ["<table>"]
+        seq_ = "<td>%s</td>"
+        label_ = '<td class="label">%s</td>'
+        num_row_ = '<tr class="num_row"><td></td><td><b>{:,d}</b></td></tr>'
+        for i in range(0, max_truncated_len, wrap):
+            table.append(num_row_.format(i))
+            seqblock = seqs[:, i : i + wrap].tolist()
+            for n, s in zip(name_order, seqblock):
+                s = "".join(s)
+                # Filter out rows that are empty (due to combination of shorter sequences + wrapping)
+                if len(s) > 0:
+                    row = "".join([label_ % n, seq_ % s])
+                    table.append(f"<tr>{row}</tr>")
+        table.append("</table>")
+        if (
+            limit
+            and limit < len(selected)
+            or name_order
+            and len(name_order) < len(selected.names)
+        ):
+            summary = (
+                "%s x {min=%s, median=%s, max=%s} (truncated to %s x %s) %s sequence collection"
+            ) % (
+                self.num_seqs,
+                min_seq_len,
+                med_seq_len,
+                max_seq_len,
+                len(name_order) if name_order else len(selected.names),
+                limit if limit else len(selected),
+                selected.moltype.label,
+            )
+        else:
+            summary = ("%s x {min=%s, median=%s, max=%s} %s sequence collection") % (
+                self.num_seqs,
+                min_seq_len,
+                med_seq_len,
+                max_seq_len,
+                selected.moltype.label,
+            )
+
+        text = [
+            "<style>",
+            ".c3align table {margin: 10px 0;}",
+            ".c3align td { border: none !important; text-align: left !important; }",
+            ".c3align tr:not(.num_row) td span {margin: 0 2px;}",
+            ".c3align tr:nth-child(even) {background: #f7f7f7;}",
+            ".c3align .num_row {background-color:rgba(161, 195, 209, 0.5) !important; border-top: solid 1px black; }",
+            ".c3align .label { font-size: %dpt ; text-align: right !important; "
+            "color: black !important; padding: 0 4px; display: table-cell !important; "
+            "font-weight: normal !important; }" % font_size,
+            "\n".join([".c3align " + style for style in css]),
+            "</style>",
+            '<div class="c3align">',
+            "\n".join(table),
+            f"<p><i>{summary}</i></p>",
+            "</div>",
+        ]
+        return "\n".join(text)
+
 
 @total_ordering
 class Aligned:
     """One sequence in an alignment, a map between alignment coordinates and
     sequence coordinates"""
 
-    def __init__(self, map, data, length=None):
-        # Unlike the normal map constructor, here we take a list of pairs of
-        # alignment coordinates, NOT a list of pairs of sequence coordinates
-        if isinstance(map, list):
-            map = Map(map, parent_length=length).inverse()
+    @c3warn.deprecated_args(
+        "2024.9", reason="now requires an IndelMap", discontinued=["length"]
+    )
+    def __init__(self, map, data):
         self.map = map
         self.data = data
         if hasattr(data, "info"):
@@ -2178,26 +2298,18 @@ def deepcopy(self, sliced=True, exclude_annotations=False):
         -------
         a copy of self
         """
-        new_seq = self.data.copy(exclude_annotations=exclude_annotations)
-        db = new_seq.annotation_db
+        new_seq = self.data.copy(exclude_annotations=exclude_annotations, sliced=sliced)
         if sliced:
-            span = self.map.get_covering_span()
-            new_seq = type(new_seq)(
-                str(new_seq[span.start : span.end]), info=new_seq.info, name=self.name
-            )
-            new_seq.annotation_offset = self.map.start
-            if self.map.reverse or exclude_annotations:
+            db = new_seq.annotation_db
+            *_, strand = self.data.parent_coordinates()
+            if strand == "-" or exclude_annotations:
                 new_seq.annotation_db = None
             else:
-                new_seq.annotation_offset = self.map.start
                 new_seq.annotation_db = db
-            new_map = self.map.zeroed()
-        else:
-            new_map = self.map
-
+        new_map = self.map
         return self.__class__(new_map, new_seq)
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return f"{self.map!r} of {self.data!r}"
 
     def with_termini_unknown(self):
@@ -2238,21 +2350,64 @@ def __len__(self):
 
     def __add__(self, other):
         if self.data is other.data:
-            (map, seq) = (self.map + other.map, self.data)
+            map, seq = self.map + other.map, self.data
         else:
             seq = self.get_gapped_seq() + other.get_gapped_seq()
-            (map, seq) = seq.parse_out_gaps()
+            map, seq = seq.parse_out_gaps()
         return Aligned(map, seq)
 
-    def __getitem__(self, slice):
-        new_map = self.map[slice]
-        if new_map.reverse:
-            # A reverse slice means we should have an empty sequence
-            new_map = type(new_map)(locations=(), parent_length=len(self.data))
-        return Aligned(new_map, self.data)
+    @singledispatchmethod
+    def __getitem__(self, span: int):
+        raise NotImplementedError(f"unexpected {type(span)}")
+
+    @__getitem__.register
+    def _(self, span: int):
+        return self[span : span + 1]
+
+    @__getitem__.register
+    def _(self, span: FeatureMap):
+        # we assume the feature map is in align coordinates
+        start, end = span.start, span.end
+        if span.useful and len(list(span.spans)) == 1:
+            im = self.map[start:end]
+            seq_start = self.map.get_seq_index(start)
+            seq_end = self.map.get_seq_index(end)
+            data = self.data[seq_start:seq_end]
+        elif not span.useful:
+            im = self.map[start:end]
+            data = self.data[:0]
+        else:
+            # multiple spans
+            align_coords = span.get_coordinates()
+            im = self.map.joined_segments(align_coords)
+            seq_map = self.map.make_seq_feature_map(span)
+            data = self.data.gapped_by_map(seq_map)
+
+        return Aligned(im, data)
+
+    @__getitem__.register
+    def _(self, span: slice):
+        # todo we need to get the sequence coordinates that slice corresponds to
+        #  so we can update the self.data
+        new_map = self.map[span]
+        seq_start = self.map.get_seq_index(span.start or 0)
+        seq_end = self.map.get_seq_index(span.stop or len(self))
+        data = self.data[seq_start:seq_end] if new_map.useful else self.data[:0]
+        if new_map.useful and seq_start > seq_end:
+            # For now, a reverse slice means we should have an empty sequence
+            # todo modify this clause if a negative step is ever allowed
+            new_map = new_map.__class__(locations=(), parent_length=len(self.data))
+
+        return Aligned(new_map, data)
 
     def rc(self):
-        return Aligned(self.map.reversed(), self.data)
+        # the map now no longer stores history of reversing,
+        # as this done by Sequence & SeqView
+        new_map = self.map.nucleic_reversed()
+        # in this new approach, the map is always plus strand, so
+        # the following comprehension ensures the order of spans
+        # and the span start/end satisfy this
+        return Aligned(new_map, self.data.rc())
 
     def to_rna(self):
         return Aligned(self.map, self.data.to_rna())
@@ -2261,12 +2416,9 @@ def to_dna(self):
         return Aligned(self.map, self.data.to_dna())
 
     def to_rich_dict(self):
-        coords = self.map.get_covering_span().get_coordinates()
-        if len(coords) != 1:
-            raise NotImplementedError
         data = dict(version=__version__, type=get_object_provenance(self))
         # we are resetting the stored data to begin at 0
-        data["map_init"] = self.map.zeroed().to_rich_dict()
+        data["map_init"] = self.map.to_rich_dict()
         data["seq_init"] = self.data.to_rich_dict(exclude_annotations=True)
         return data
 
@@ -2277,7 +2429,7 @@ def from_rich_dict(cls, data: dict):
             deserialise_seq,
         )
 
-        map_ = deserialise_map_spans(data["map_init"])
+        map_ = IndelMap.from_rich_dict(data["map_init"])
         seq = deserialise_seq(data["seq_init"])
         return cls(map_, seq)
 
@@ -2305,7 +2457,9 @@ def remapped_to(self, map):
     def make_feature(self, feature: FeatureDataType, alignment: "Alignment") -> Feature:
         """returns a feature, not written into annotation_db"""
         annot = self.data.make_feature(feature)
-        return annot.remapped_to(alignment, self.map.inverse())
+        inverted = self.map.to_feature_map().inverse()
+        # todo should indicate whether tidy or not
+        return annot.remapped_to(alignment, inverted)
 
     def gap_vector(self):
         """Returns gap_vector of GappedSeq, for omit_gap_pos."""
@@ -2355,6 +2509,24 @@ class AlignmentI(object):
     default_gap = "-"  # default gap character for padding
     gap_chars = dict.fromkeys("-?")  # default gap chars for comparisons
 
+    def __repr__(self):
+        seqs = []
+        limit = 10
+        delimiter = ""
+        for count, name in enumerate(self.names):
+            if count == 3:
+                seqs.append("...")
+                break
+            elts = list(str(self.named_seqs[name])[: limit + 1])
+            if len(elts) > limit:
+                elts[-1] = "..."
+            seqs.append(f"{name}[{delimiter.join(elts)}]")
+        seqs = ", ".join(seqs)
+
+        return (
+            f"{len(self.names)} x {self.seq_len} {self.moltype.label} alignment: {seqs}"
+        )
+
     def alignment_quality(self, app_name: str = "ic_score", **kwargs):
         """
         Computes the alignment quality using the indicated app
@@ -2454,12 +2626,17 @@ def take_positions_if(self, f, negate=False):
         """Returns new Alignment containing cols where f(col) is True."""
         return self.take_positions(self.get_position_indices(f, negate=negate))
 
-    def iupac_consensus(self, alphabet=None, allow_gaps=True):
+    @c3warn.deprecated_args(
+        "2024.6",
+        reason="consistency with other methods",
+        old_new=[("allow_gaps", "allow_gap")],
+    )
+    def iupac_consensus(self, alphabet=None, allow_gap=True):
         """Returns string containing IUPAC consensus sequence of the alignment."""
         if alphabet is None:
             alphabet = self.moltype
 
-        exclude = set() if allow_gaps else set(alphabet.gaps)
+        exclude = set() if allow_gap else set(alphabet.gaps)
         consensus = []
         degen = alphabet.degenerate_from_seq
         for col in self.positions:
@@ -2483,26 +2660,26 @@ def majority_consensus(self):
         return self.moltype.make_seq("".join(states))
 
     def probs_per_pos(
-        self, motif_length=1, include_ambiguity=False, allow_gap=False, alert=False
+        self, motif_length=1, include_ambiguity=False, allow_gap=False, warn=False
     ):
         """returns MotifFreqsArray per position"""
         counts = self.counts_per_pos(
             motif_length=motif_length,
             include_ambiguity=include_ambiguity,
             allow_gap=allow_gap,
-            alert=alert,
+            warn=warn,
         )
         return counts.to_freq_array()
 
     def entropy_per_pos(
-        self, motif_length=1, include_ambiguity=False, allow_gap=False, alert=False
+        self, motif_length=1, include_ambiguity=False, allow_gap=False, warn=False
     ):
         """returns shannon entropy per position"""
         probs = self.probs_per_pos(
             motif_length=motif_length,
             include_ambiguity=include_ambiguity,
             allow_gap=allow_gap,
-            alert=alert,
+            warn=warn,
         )
         return probs.entropy()
 
@@ -2512,7 +2689,7 @@ def probs_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=False,
-        alert=False,
+        warn=False,
     ):
         """return MotifFreqsArray per sequence
 
@@ -2527,6 +2704,9 @@ def probs_per_seq(
             if True, motifs containing a gap character are included.
         exclude_unobserved
             if True, unobserved motif combinations are excluded.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
         """
 
         counts = self.counts_per_seq(
@@ -2534,11 +2714,9 @@ def probs_per_seq(
             include_ambiguity=include_ambiguity,
             allow_gap=allow_gap,
             exclude_unobserved=exclude_unobserved,
+            warn=warn,
         )
-        if counts is None:
-            return None
-
-        return counts.to_freq_array()
+        return None if counts is None else counts.to_freq_array()
 
     def entropy_per_seq(
         self,
@@ -2546,7 +2724,7 @@ def entropy_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=True,
-        alert=False,
+        warn=False,
     ):
         """returns the Shannon entropy per sequence
 
@@ -2561,6 +2739,9 @@ def entropy_per_seq(
             if True, motifs containing a gap character are included.
         exclude_unobserved
             if True, unobserved motif combinations are excluded.
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
 
         Notes
         -----
@@ -2573,12 +2754,9 @@ def entropy_per_seq(
             include_ambiguity=include_ambiguity,
             allow_gap=allow_gap,
             exclude_unobserved=exclude_unobserved,
-            alert=alert,
+            warn=warn,
         )
-        if probs is None:
-            return None
-
-        return probs.entropy()
+        return None if probs is None else probs.entropy()
 
     def no_degenerates(self, motif_length=1, allow_gap=False):
         """returns new alignment without degenerate characters
@@ -2587,7 +2765,7 @@ def no_degenerates(self, motif_length=1, allow_gap=False):
         ----------
         motif_length
             sequences are segmented into units of this size
-        allow_gaps
+        allow_gap
             whether gaps are to be treated as a degenerate
             character (default, most evolutionary modelling treats gaps as
             N) or not.
@@ -2630,10 +2808,7 @@ def omit_gap_pos(self, allowed_gap_frac=1 - eps, motif_length=1):
         gaps_ok = GapsOk(
             gaps, allowed_gap_frac, is_array=False, motif_length=motif_length
         )
-        # if we're not deleting the 'naughty' seqs that contribute to the
-        # gaps, it's easy...
-        result = self.filtered(gaps_ok, motif_length=motif_length)
-        return result
+        return self.filtered(gaps_ok, motif_length=motif_length)
 
     def get_gap_array(self, include_ambiguity=True):
         """returns bool array with gap state True, False otherwise
@@ -2803,8 +2978,15 @@ def sample(
         positions = [
             (loc * motif_length, (loc + 1) * motif_length) for loc in locations
         ]
-        sample = Map(positions, parent_length=len(self))
-        return self.gapped_by_map(sample, info=self.info)
+        make_seq = self.moltype.make_seq
+        new_seqs = []
+        for seq in self.seqs:
+            seq = make_seq(
+                "".join(str(seq[x1:x2]) for x1, x2 in positions), name=seq.name
+            )
+            new_seqs.append(seq)
+
+        return self.__class__(new_seqs, info=self.info, moltype=self.moltype)
 
     def sliding_windows(self, window, step, start=None, end=None):
         """Generator yielding new alignments of given length and interval.
@@ -2853,7 +3035,7 @@ def _get_raw_pretty(self, name_order):
 
         return names, output
 
-    def _repr_html_(self):
+    def _repr_html_(self) -> str:
         settings = self._repr_policy.copy()
         env_vals = get_setting_from_environ(
             "COGENT3_ALIGNMENT_REPR_POLICY",
@@ -2869,14 +3051,14 @@ def _repr_html_(self):
 
     def to_html(
         self,
-        name_order=None,
-        wrap=60,
-        limit=None,
-        ref_name="longest",
-        colors=None,
-        font_size=12,
-        font_family="Lucida Console",
-    ):
+        name_order: Optional[typing.Sequence[str]] = None,
+        wrap: int = 60,
+        limit: Optional[int] = None,
+        ref_name: str = "longest",
+        colors: Optional[Mapping[str, str]] = None,
+        font_size: int = 12,
+        font_family: str = "Lucida Console",
+    ) -> str:
         """returns html with embedded styles for sequence colouring
 
         Parameters
@@ -2900,16 +3082,28 @@ def to_html(
         font_family
             string denoting font family
 
-        To display in jupyter notebook:
+        Examples
+        ---------
+
+        In a jupyter notebook, this code is used to provide the representation.
+
+        .. code-block:: python
+
+            aln # is rendered by jupyter
+
+        You can directly use the result for display in a notebook as
 
-            >>> from IPython.core.display import HTML
-            >>> HTML(aln.to_html())
+        .. code-block:: python
+
+            from IPython.core.display import HTML
+            HTML(aln.to_html())
         """
         css, styles = self.moltype.get_css_style(
             colors=colors, font_size=font_size, font_family=font_family
         )
         if name_order:
             selected = self.take_seqs(name_order)
+            name_order = list(name_order)
         else:
             name_order = list(self.names)
             ref_name = ref_name or "longest"
@@ -3068,19 +3262,19 @@ def make_line(label, seq):
         return "\n".join(result)
 
     def counts_per_pos(
-        self, motif_length=1, include_ambiguity=False, allow_gap=False, alert=False
+        self, motif_length=1, include_ambiguity=False, allow_gap=False, warn=False
     ):
         """return DictArray of counts per position
 
         Parameters
         ----------
 
-        alert
+        warn
             warns if motif_length > 1 and alignment trimmed to produce
             motif columns
         """
         length = (len(self) // motif_length) * motif_length
-        if alert and len(self) != length:
+        if warn and len(self) != length:
             warnings.warn(f"trimmed {len(self) - length}", UserWarning)
 
         data = list(self.to_dict().values())
@@ -3120,7 +3314,7 @@ def counts_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=False,
-        alert=False,
+        warn=False,
     ):
         """counts of non-overlapping motifs per sequence
 
@@ -3131,11 +3325,11 @@ def counts_per_seq(
         include_ambiguity
             if True, motifs containing ambiguous characters
             from the seq moltype are included. No expansion of those is attempted.
-        allow_gaps
+        allow_gap
             if True, motifs containing a gap character are included.
         exclude_unobserved
             if False, all canonical states included
-        alert
+        warn
             warns if motif_length > 1 and alignment trimmed to produce
             motif columns
 
@@ -3144,7 +3338,7 @@ def counts_per_seq(
         MotifCountsArray
         """
         length = (len(self) // motif_length) * motif_length
-        if alert and len(self) != length:
+        if warn and len(self) != length:
             warnings.warn(f"trimmed {len(self) - length}", UserWarning)
 
         counts = []
@@ -3260,7 +3454,7 @@ def distance_matrix(self, calc="pdist", show_progress=False, drop_invalid=False)
     @extend_docstring_from(distance_matrix, pre=False)
     def quick_tree(
         self,
-        calc="percent",
+        calc="pdist",
         bootstrap=None,
         drop_invalid=False,
         show_progress=False,
@@ -3649,183 +3843,6 @@ def _one_length(seqs):
         raise ValueError("not all sequences have same length")
 
 
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_array(a, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from array of pos x seq: no change, names are integers.
-
-    This is an InputHandler for Alignment. It converts an arbitrary array
-    of numbers without change, but adds successive integer names (0-based) to
-    each sequence (i.e. column) in the input a. Data type of input is
-    unchanged.
-    """
-    if array_type is None:
-        result = a.copy()
-    else:
-        result = a.astype(array_type)
-    return transpose(result), None
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_array_seqs(seqs, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from ArraySequence objects: seqs -> array, names from seqs.
-
-    This is an InputHandler for Alignment. It converts a list of Sequence
-    objects with _data and label properties into the character array Alignment
-    needs. All sequences must be the same length.
-
-    WARNING: Assumes that the ArraySeqs are already in the right alphabet. If
-    this is not the case, e.g. if you are putting sequences on a degenerate
-    alphabet into a non-degenerate alignment or you are putting protein
-    sequences into a DNA alignment, there will be problems with the alphabet
-    mapping (i.e. the resulting sequences may be meaningless).
-
-    WARNING: Data type of return array is not guaranteed -- check in caller!
-    """
-    data, names = [], []
-    for s in seqs:
-        data.append(s._data)
-        names.append(s.name)
-
-    _one_length(data)
-
-    result = array(data)
-    if array_type:
-        result = result.astype(array_type)
-    return result, names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_generic(data, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from generic seq x pos data: sequence of sequences of chars.
-
-    This is an InputHandler for Alignment. It converts a generic list (each
-    item in the list will be mapped onto an Array object, with character
-    transformations, all items must be the same length) into a numpy array,
-    and assigns sequential integers (0-based) as names.
-
-    WARNING: Data type of return array is not guaranteed -- check in caller!
-    """
-    result = array([alphabet.to_indices(v) for v in data], dtype=object).astype(int)
-    names = []
-    for d in data:
-        if hasattr(d, "name"):
-            names.append(d.name)
-        else:
-            names.append(None)
-    if array_type:
-        result = result.astype(array_type)
-    return result, names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_collection(seqs, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from SequenceCollection object, or its subclasses."""
-    names = seqs.names
-    data = [seqs.named_seqs[i] for i in names]
-    result = array(list(map(alphabet.to_indices, data)))
-    if array_type:
-        result = result.astype(array_type)
-    return result, names
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_fasta(seqs, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from FASTA-format string or lines.
-
-    This is an InputHandler for Alignment. It converts a FASTA-format string
-    or collection of lines into an Alignment object. All sequences must be the
-    same length.
-
-    WARNING: Data type of return array is not guaranteed -- check in caller!
-    """
-    if isinstance(seqs, bytes):
-        seqs = seqs.decode("utf-8")
-    if isinstance(seqs, str):
-        seqs = seqs.splitlines()
-    return aln_from_array_seqs(
-        [
-            ArraySequence(s, name=l, alphabet=alphabet)
-            for l, s in cogent3.parse.fasta.MinimalFastaParser(seqs)
-        ],
-        array_type,
-    )
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_dict(aln, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from dict of {label:seq_as_str}.
-
-    This is an InputHandler for Alignment. It converts a dict in which the
-    keys are the names and the values are the sequences (sequence only, no
-    whitespace or other formatting) into an alignment. Because the dict
-    doesn't preserve order, the result will be in alphabetical order."""
-    for n in aln:
-        try:
-            aln[n] = aln[n].upper()
-        except AttributeError:
-            pass
-
-    names, seqs = list(zip(*sorted(aln.items())))
-    seqs = [bytes_to_string(s) for s in seqs]
-    _one_length(seqs)
-    result = array(list(map(alphabet.to_indices, seqs)), array_type)
-    return result, list(names)
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_kv_pairs(aln, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from sequence of (key, value) pairs.
-
-    This is an InputHandler for Alignment. It converts a list in which the
-    first item of each pair is the label and the second item is the sequence
-    (sequence only, no whitespace or other formatting) into an alignment.
-    Because the dict doesn't preserve order, the result will be in arbitrary
-    order."""
-    names, seqs = list(zip(*aln))
-    seqs = [bytes_to_string(s) for s in seqs]
-    _one_length(seqs)
-    result = array(list(map(alphabet.to_indices, seqs)), array_type)
-    return result, list(names)
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_array_aln(aln, array_type=None, alphabet=None):  # pragma: no cover
-    """Alignment from existing ArrayAlignment object: copies data.
-
-    Retrieves data from positions field. Uses copy(), so array data type
-    should be unchanged.
-    """
-    if array_type is None:
-        result = aln.array_positions.copy()
-    else:
-        result = aln.array_positions.astype(array_type)
-    return transpose(result), aln.names[:]
-
-
-@c3warn.deprecated_args(
-    "2024.3", reason="replaced by different loading mechanism", discontinued=True
-)
-def aln_from_empty(obj, *args, **kwargs):  # pragma: no cover
-    """Alignment from empty data: raise exception."""
-    raise ValueError("Cannot create empty alignment.")
-
-
 # Implementation of Alignment base class
 
 
@@ -3904,18 +3921,21 @@ def _set_additional_attributes(self, curr_seqs):
         self._seqs = curr_seqs
         self.seq_len = curr_seqs.shape[1] if len(curr_seqs) else 0
 
-    def _get_positions(self):
+    @property
+    def positions(self):
         """Override superclass positions to return positions as symbols."""
-        return list(map(self.alphabet.from_indices, self.array_positions))
-
-    positions = property(_get_positions)
+        from_indices = self.alphabet.from_indices
+        return [list(from_indices(pos)) for pos in self.array_positions]
 
     @property
     def named_seqs(self):
         if self._named_seqs is None:
-            seqs = list(map(self.alphabet.to_string, self.array_seqs))
+            seqs = [self.alphabet.to_string(seq) for seq in self.array_seqs]
             if self.moltype:
-                seqs = [self.moltype.make_seq(s, preserve_case=True) for s in seqs]
+                seqs = [
+                    self.moltype.make_seq(seq, name, preserve_case=True)
+                    for seq, name in zip(seqs, self.names)
+                ]
             self._named_seqs = _make_named_seqs(self.names, seqs)
         return self._named_seqs
 
@@ -3953,11 +3973,6 @@ def _coerce_seqs(self, seqs):
         """
         return seqs
 
-    @c3warn.deprecated_args(
-        "2024.3",
-        reason="naming consistency",
-        old_new=(("invert_seqs", "negate_seqs"), ("invert_pos", "negate_pos")),
-    )
     def get_sub_alignment(
         self, seqs=None, pos=None, negate_seqs=False, negate_pos=False
     ):
@@ -3995,6 +4010,9 @@ def get_sub_alignment(
             names = [self.names[i] for i in seqs]
         else:
             names = self.names
+        if not len(names):
+            return None
+
         return self.__class__(
             data.T,
             list(map(str, names)),
@@ -4013,28 +4031,17 @@ def __str__(self):
             result.append(">" + str(l) + "\n" + "".join(seq2str(s)))
         return "\n".join(result) + "\n"
 
-    def __repr__(self):
-        seqs = []
-        limit = 10
-        delimiter = ""
-        for count, name in enumerate(self.names):
-            if count == 3:
-                seqs.append("...")
-                break
-            elts = list(str(self.named_seqs[name])[: limit + 1])
-            if len(elts) > limit:
-                elts.append("...")
-            seqs.append(f"{name}[{delimiter.join(elts)}]")
-        seqs = ", ".join(seqs)
-
-        return f"{len(self.names)} x {self.seq_len} alignment: {seqs}"
-
-    def iupac_consensus(self, alphabet=None, allow_gaps=True):
+    @c3warn.deprecated_args(
+        "2024.6",
+        reason="consistency with other methods",
+        old_new=[("allow_gaps", "allow_gap")],
+    )
+    def iupac_consensus(self, alphabet=None, allow_gap=True):
         """Returns string containing IUPAC consensus sequence of the alignment."""
         if alphabet is None:
             alphabet = self.moltype
 
-        exclude = set() if allow_gaps else set(alphabet.gaps)
+        exclude = set() if allow_gap else set(alphabet.gaps)
         consensus = []
         degen = alphabet.degenerate_from_seq
         for col in self.positions:
@@ -4179,7 +4186,7 @@ def get_degapped_relative_to(self, name):
         new = self.array_seqs[:, indices]
 
         return self.__class__(
-            new.T, names=self.names, moltype=self.moltype, info=self.info
+            new, names=self.names, moltype=self.moltype, info=self.info
         )
 
     def add_from_ref_aln(self, ref_aln, before_name=None, after_name=None):
@@ -4390,7 +4397,7 @@ def no_degenerates(self, motif_length=1, allow_gap=False):
         ----------
         motif_length
             sequences are segmented into units of this size
-        allow_gaps
+        allow_gap
             whether gaps are to be treated as a degenerate
             character (default, most evolutionary modelling treats gaps as
             N) or not.
@@ -4421,7 +4428,7 @@ def counts_per_seq(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=False,
-        alert=False,
+        warn=False,
     ):
         """counts of non-overlapping motifs per sequence
 
@@ -4432,11 +4439,11 @@ def counts_per_seq(
         include_ambiguity
             if True, motifs containing ambiguous characters
             from the seq moltype are included. No expansion of those is attempted.
-        allow_gaps
+        allow_gap
             if True, motifs containing a gap character are included.
         exclude_unobserved
             if False, all canonical states included
-        alert
+        warn
             warns if motif_length > 1 and alignment trimmed to produce
             motif columns
 
@@ -4445,7 +4452,7 @@ def counts_per_seq(
         MotifCountsArray
         """
         length = (len(self) // motif_length) * motif_length
-        if alert and len(self) != length:
+        if warn and len(self) != length:
             warnings.warn(f"trimmed {len(self) - length}", UserWarning)
 
         counts = []
@@ -4552,15 +4559,6 @@ def get_gap_array(self, include_ambiguity=True):
         return gapped
 
 
-@c3warn.deprecated_callable(
-    "2024.3", reason="not being used", is_discontinued=True, stack_level=3
-)
-class CodonArrayAlignment(ArrayAlignment):  # pragma: no cover
-    """Stores alignment of gapped codons, no degenerate symbols."""
-
-    ...
-
-
 def make_gap_filter(template, gap_fraction, gap_run):
     """Returns f(seq) -> True if no gap runs and acceptable gap fraction.
 
@@ -4628,7 +4626,7 @@ def __getitem__(self, index):
                 raise ValueError(f"feature.parent {index.seqid!r} is not self")
             return index.get_slice()
 
-        if isinstance(index, Map):
+        if isinstance(index, (FeatureMap, IndelMap)):
             new = self._mapped(index)
 
         elif isinstance(index, (int, slice)):
@@ -4637,6 +4635,7 @@ def __getitem__(self, index):
             new = self.__class__(
                 seqs, name=self.name, info=self.info, moltype=self.moltype
             )
+            new.annotation_db = self.annotation_db
 
         if isinstance(index, (list, tuple)):
             raise TypeError(f"cannot slice using {type(index)}")
@@ -4649,22 +4648,6 @@ def __getitem__(self, index):
 
         return new
 
-    def __repr__(self):
-        seqs = []
-        limit = 10
-        delimiter = ""
-        for count, name in enumerate(self.names):
-            if count == 3:
-                seqs.append("...")
-                break
-            elts = list(str(self.named_seqs[name])[: limit + 1])
-            if len(elts) > limit:
-                elts.append("...")
-            seqs.append(f"{name}[{delimiter.join(elts)}]")
-        seqs = ", ".join(seqs)
-
-        return f"{len(self.names)} x {self.seq_len} alignment: {seqs}"
-
     @property
     def annotation_db(self):
         return self._annotation_db
@@ -4687,18 +4670,18 @@ def annotation_db(self, value):
 
     def _mapped(self, slicemap):
         align = []
-        for name in self.names:
-            align.append((name, self.named_seqs[name][slicemap]))
+        for seq in self.seqs:
+            sliced = seq[slicemap]
+            align.append((sliced.name, sliced))
         return self.__class__(moltype=self.moltype, data=align, info=self.info)
 
     def gapped_by_map(self, keep, **kwargs):
         # keep is a Map
+        # seqs = [seq[keep] for seq in self.seqs]
         seqs = []
-        for seq_name in self.names:
-            aligned = self.named_seqs[seq_name]
-            seqmap = aligned.map[keep]
-            seq = aligned.data.gapped_by_map(seqmap)
-            seqs.append((seq_name, seq))
+        for seq in self.seqs:
+            selected = seq[keep]
+            seqs.append(selected)
         return self.__class__(moltype=self.moltype, data=seqs, **kwargs)
 
     def get_projected_feature(self, *, seqid: str, feature: Feature) -> Feature:
@@ -4800,8 +4783,9 @@ def filtered(self, predicate, motif_length=1, drop_remainder=True, **kwargs):
             return None
 
         locations = [(gv[i], gv[i + 1]) for i in range(0, len(gv), 2)]
+        # these are alignment coordinate locations
+        keep = FeatureMap.from_locations(locations=locations, parent_length=len(self))
 
-        keep = Map(locations, parent_length=len(self))
         return self.gapped_by_map(keep, info=self.info)
 
     def get_seq(self, seqname):
@@ -4809,8 +4793,7 @@ def get_seq(self, seqname):
 
         Note: always returns Sequence object, not ArraySequence.
         """
-        seq = self.named_seqs[seqname]
-        return seq.data[seq.map.without_gaps()]
+        return self.named_seqs[seqname].data
 
     def get_gapped_seq(self, seq_name, recode_gaps=False):
         """Return a gapped Sequence object for the specified seqname.
@@ -5196,12 +5179,13 @@ def make_feature(
 
         feature["seqid"] = feature.get("seqid", None)
         # there's no sequence to bind to, the feature is directly on self
-        # todo gah check handling of strand etc..., maybe reuse code
-        # in Sequence?
-        fmap = Map(parent_length=len(self), locations=feature.pop("spans"))
-        if feature.pop("reversed", None):
+        revd = feature.pop("strand", None) == "-"
+        feature["strand"] = "-" if revd else "+"
+        fmap = FeatureMap.from_locations(
+            locations=feature.pop("spans"), parent_length=len(self)
+        )
+        if revd:
             fmap = fmap.nucleic_reversed()
-        feature.pop("strand", None)
         return Feature(parent=self, map=fmap, **feature)
 
     def _get_seq_features(
@@ -5234,27 +5218,39 @@ def _get_seq_features(
         if self.annotation_db is None:
             return None
 
+        seqid_to_seqname = {
+            seq.data.parent_coordinates()[0]: seq.name for seq in self.seqs
+        }
+
         seqids = [seqid] if isinstance(seqid, str) else seqid
         if seqids is None:
-            seqids = self.names
-
-        if seqid and not set(seqids) & set(self.names):
+            seqids = tuple(seqid_to_seqname)
+        elif set(seqids) & set(self.names):
+            # we've been given seq names, convert to parent names
+            seqids = [
+                self.named_seqs[seqid].data.parent_coordinates()[0] for seqid in seqids
+            ]
+        elif seqids and set(seqids) <= seqid_to_seqname.keys():
+            # already correct
+            pass
+        else:
             raise ValueError(f"unknown {seqid=}")
 
         for seqid in seqids:
-            seq = self.named_seqs[seqid]
-
-            start, end = seq.map.start, seq.map.end
+            seqname = seqid_to_seqname[seqid]
+            seq = self.named_seqs[seqname]
+            # we use parent seqid, stored on SeqView
+            parent_id, start, stop, _ = seq.data.parent_coordinates()
             offset = seq.data.annotation_offset
 
             for feature in self.annotation_db.get_features_matching(
-                seqid=seqid,
+                seqid=parent_id,
                 biotype=biotype,
                 name=name,
                 on_alignment=False,
                 allow_partial=allow_partial,
                 start=start,
-                end=end,
+                stop=stop,
             ):
                 if offset:
                     feature["spans"] = (array(feature["spans"]) - offset).tolist()
@@ -5323,13 +5319,14 @@ def get_features(
             if feature["seqid"]:
                 raise RuntimeError(f"{on_alignment=} {feature=}")
             if seq_map is None:
-                seq_map = self.seqs[0].map
+                seq_map = self.seqs[0].map.to_feature_map()
+                *_, strand = self.seqs[0].data.parent_coordinates()
 
             spans = numpy.array(feature["spans"])
             spans = seq_map.relative_position(spans)
             feature["spans"] = spans.tolist()
             # and if i've been reversed...?
-            feature["reversed"] = seq_map.reverse
+            feature["strand"] = "-" if strand == -1 else "+"
             yield self.make_feature(feature=feature, on_alignment=on_al)
 
 
@@ -5380,8 +5377,7 @@ def _coerce_to_unaligned_seqs(data, names, label_to_name=str, moltype=None) -> O
     names = names or list(data.keys())
     seqs = []
     for name in names:
-        seq = _construct_unaligned_seq(data[name], moltype=moltype)
-        seq.name = name
+        seq = _construct_unaligned_seq(data[name], name=name, moltype=moltype)
         seqs.append(seq)
     return seqs, names
 
@@ -5483,6 +5479,15 @@ def _coerce_to_array_aligned_seqs(data, names, label_to_name=str, moltype=None)
         seq = _construct_array_aligned_seq(data[name], moltype=moltype)
         seqs.append(seq)
 
+    # Ensure all sequences are of equal length
+    seq_lengths = {len(seq) for seq in seqs}
+    if len(seq_lengths) > 1:
+        raise ValueError(
+            f"Input sequences are not all the same length: {seq_lengths}. "
+            "Please ensure all sequences are properly aligned or "
+            "correct the input file format."
+        )
+
     return numpy.array(seqs), names
 
 
@@ -5561,8 +5566,7 @@ def _coerce_to_aligned_seqs(data, names, label_to_name=str, moltype=None) -> O:
     names = names or list(data.keys())
     seqs = []
     for name in names:
-        seq = _construct_aligned_seq(data[name], moltype=moltype)
-        seq.name = seq.data.name = name
+        seq = _construct_aligned_seq(data[name], name=name, moltype=moltype)
         seqs.append(seq)
 
     _one_length(seqs)
@@ -5624,53 +5628,63 @@ def _make_named_seqs(
 
 
 @functools.singledispatch
-def _construct_unaligned_seq(data, moltype) -> Sequence:
+def _construct_unaligned_seq(data, name, moltype) -> Sequence:
     try:
         result = data.to_moltype(moltype) if moltype else data
     except AttributeError:
-        result = moltype.make_seq(data, preserve_case=False)
+        result = moltype.make_seq(data, name=name, preserve_case=False)
+    result.name = name
     return result
 
 
 @_construct_unaligned_seq.register
-def _(data: str, moltype) -> Sequence:
-    return moltype.make_seq(data, preserve_case=False)
+def _(data: str, name, moltype) -> Sequence:
+    return moltype.make_seq(data, name=name, preserve_case=False)
 
 
 @_construct_unaligned_seq.register
-def _(data: bytes, moltype) -> Sequence:
-    return _construct_unaligned_seq(data.decode("utf8"), moltype)
+def _(data: bytes, name, moltype) -> Sequence:
+    return _construct_unaligned_seq(data.decode("utf8"), name=name, moltype=moltype)
 
 
 @_construct_unaligned_seq.register
-def _(data: Aligned, moltype) -> Sequence:
+def _(data: Aligned, name, moltype) -> Sequence:
+    data.name = name
     return data.get_gapped_seq().to_moltype(moltype)
 
 
 @_construct_unaligned_seq.register
-def _(data: ndarray, moltype) -> Sequence:
-    return moltype.make_array_seq(data)
+def _(data: ArraySequence, name, moltype) -> Sequence:
+    assert name == data.name
+    return moltype.make_seq(str(data), name=name, info=data.info)
+
+
+@_construct_unaligned_seq.register
+def _(data: ndarray, name, moltype) -> Sequence:
+    return moltype.make_array_seq(data, name=name)
 
 
 @functools.singledispatch
-def _construct_aligned_seq(data, moltype) -> Aligned:
-    seq = _construct_unaligned_seq(data, moltype)
+def _construct_aligned_seq(data: str, name, moltype) -> Aligned:
+    seq = _construct_unaligned_seq(data, name, moltype)
+    seq.name = name
     return Aligned(*seq.parse_out_gaps())
 
 
 @_construct_aligned_seq.register
-def _(data: Aligned, moltype) -> Aligned:
+def _(data: Aligned, name, moltype) -> Aligned:
+    data.name = name
     return data
 
 
 @_construct_aligned_seq.register
-def _(data: list, moltype) -> Aligned:
-    return _construct_aligned_seq("".join(data), moltype)
+def _(data: list, name, moltype) -> Aligned:
+    return _construct_aligned_seq("".join(data), name, moltype)
 
 
 @_construct_aligned_seq.register
-def _(data: tuple, moltype) -> Aligned:
-    return _construct_aligned_seq("".join(data), moltype)
+def _(data: tuple, name, moltype) -> Aligned:
+    return _construct_aligned_seq("".join(data), name, moltype)
 
 
 # convert seq data into a numpy array for an array alignment
diff --git a/src/cogent3/core/alphabet.py b/src/cogent3/core/alphabet.py
index a3c71b7fa..eac618fbd 100644
--- a/src/cogent3/core/alphabet.py
+++ b/src/cogent3/core/alphabet.py
@@ -16,14 +16,14 @@
 import json
 import typing
 
+from functools import singledispatchmethod
 from itertools import product
 
 from numpy import (
     arange,
     array,
-    asarray,
+    ndarray,
     newaxis,
-    ravel,
     sum,
     take,
     transpose,
@@ -33,7 +33,6 @@
     uint64,
     zeros,
 )
-from numpy.testing import assert_allclose
 
 from cogent3._version import __version__
 from cogent3.util import warning as c3warns
@@ -549,60 +548,6 @@ def get_subset(self, motif_subset, excluded=False):
             motif_subset = [m for m in self if m not in motif_subset]
         return self._with(motif_subset)
 
-    @c3warns.deprecated_callable(
-        "2024.3",
-        reason="does not belong on alphabet",
-        new="<moltype>.resolve_ambiguity()",
-    )
-    def resolve_ambiguity(self, ambig_motif):
-        """deprecated, use method on MolType"""
-        # shortcut easy case
-        if ambig_motif in self._quick_motifset:
-            return (ambig_motif,)
-
-        # resolve each letter, and build the possible sub motifs
-        ambiguities = self.moltype.ambiguities
-        motif_set = [""]
-        ALL = self.moltype.alphabet.with_gap_motif()
-        for character in ambig_motif:
-            new_motifs = []
-            if character == "?":
-                resolved = ALL
-            elif character == "-":
-                resolved = ["-"]
-            else:
-                try:
-                    resolved = ambiguities[character]
-                except KeyError:
-                    raise AlphabetError(ambig_motif)
-            for character2 in resolved:
-                for motif in motif_set:
-                    new_motifs.append("".join([motif, character2]))
-
-            motif_set = new_motifs
-
-        # delete sub motifs that are not to be included
-        motif_set = [motif for motif in motif_set if motif in self._quick_motifset]
-
-        if not motif_set:
-            raise AlphabetError(ambig_motif)
-
-        return tuple(motif_set)
-
-    @c3warns.deprecated_callable(
-        "2024.3",
-        reason="does not belong on alphabet",
-        new="cogent3.evolve.likelihood_tree.get_matched_array",
-    )
-    def get_matched_array(self, motifs, dtype=float):
-        """deprecated, use function in evolve.likelihood_tree"""
-        result = zeros([len(motifs), len(self)], dtype)
-        obj_to_index = self._obj_to_index
-        for u, ambig_motif in enumerate(motifs):
-            for motif in self.resolve_ambiguity(ambig_motif):
-                result[u, obj_to_index[motif]] = 1.0
-        return result
-
 
 class CharAlphabet(Alphabet):
     """Holds an alphabet whose items are single chars.
@@ -627,7 +572,7 @@ def __init__(self, data=None, gap="-", moltype=None):
         data = data or []
         super(CharAlphabet, self).__init__(data, gap, moltype=moltype)
         self._indices_to_chars, self._chars_to_indices = _make_translation_tables(data)
-        self._char_nums_to_indices = array(range(256), uint8)
+        self._char_nums_to_indices = array(range(256), self.array_type)
         for c, i in self._chars_to_indices.items():
             self._char_nums_to_indices[c] = i
 
@@ -675,6 +620,76 @@ def to_string(self, data, delimiter="\n"):
                 [i.tobytes().decode("utf-8") for i in self.to_chars(data)]
             )
 
+    @singledispatchmethod
+    def from_indices(self, data: ndarray) -> str:
+        """returns string from numpy array of integers"""
+        data = data.astype(dtype=self.array_type)
+        encoding = data.dtype.name.replace("uint", "utf")
+        return str.translate(data.tobytes().decode(encoding), self._indices_to_chars)
+
+    @from_indices.register
+    def _(self, data: list) -> str:
+        """returns string from numpy array of integers"""
+        return self.from_indices(array(data, dtype=self.array_type))
+
+    @from_indices.register
+    def _(self, data: tuple) -> str:
+        """returns string from numpy array of integers"""
+        return self.from_indices(array(data, dtype=self.array_type))
+
+    @singledispatchmethod
+    def to_indices(self, data) -> ndarray:
+        """Returns sequence of indices from sequence of elements.
+
+        Raises KeyError if some of the elements were not found.
+
+        Expects data to be a sequence (e.g. list of tuple) of items that
+        are in the Enumeration. Returns a list containing the index of each
+        element in the input, in order.
+
+        e.g. for the RNA alphabet ('U','C','A','G'), the sequence 'CCAU'
+        would produce the result [1,1,2,0], returning the index of each
+        element in the input.
+        """
+        return array([self._obj_to_index[e] for e in data], dtype=self.array_type)
+
+    @to_indices.register
+    def _(self, data: str) -> ndarray:
+        """Returns sequence of indices from sequence of elements.
+
+        Raises KeyError if some of the elements were not found.
+
+        Expects data to be a sequence (e.g. list of tuple) of items that
+        are in the Enumeration. Returns a list containing the index of each
+        element in the input, in order.
+
+        e.g. for the RNA alphabet ('U','C','A','G'), the sequence 'CCAU'
+        would produce the result [1,1,2,0], returning the index of each
+        element in the input.
+        """
+        return array(
+            memoryview(
+                bytearray(data.translate(self._chars_to_indices).encode("utf8"))
+            ),
+            dtype=self.array_type,
+        )
+
+    @to_indices.register
+    def _(self, data: bytes) -> ndarray:
+        """Returns sequence of indices from sequence of elements.
+
+        Raises KeyError if some of the elements were not found.
+
+        Expects data to be a sequence (e.g. list of tuple) of items that
+        are in the Enumeration. Returns a list containing the index of each
+        element in the input, in order.
+
+        e.g. for the RNA alphabet ('U','C','A','G'), the sequence 'CCAU'
+        would produce the result [1,1,2,0], returning the index of each
+        element in the input.
+        """
+        return self.to_indices(data.decode("utf8"))
+
 
 T = typing.Union[Alphabet, CharAlphabet]
 
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index ddccc04d4..fa2b8f8c2 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -2,9 +2,7 @@
 
 from numpy import array
 
-from cogent3.util import warning as c3warn
-
-from .location import Map
+from .location import FeatureMap
 
 
 # todo gah write docstrings!
@@ -20,10 +18,20 @@ class Feature:
         "_name",
         "_serialisable",
         "_id",
+        "_strand",
     )
 
     # todo gah implement a __new__ to trap args for serialisation purposes?
-    def __init__(self, *, parent, seqid: str, map: Map, biotype: str, name: str):
+    def __init__(
+        self,
+        *,
+        parent,
+        seqid: str,
+        map: FeatureMap,
+        biotype: str,
+        name: str,
+        strand: str,
+    ):
         # _serialisable is used for creating derivative instances
         d = locals()
         exclude = ("self", "__class__", "kw")
@@ -37,6 +45,7 @@ def __init__(self, *, parent, seqid: str, map: Map, biotype: str, name: str):
         data = [id(self.parent), tuple(self.map.get_coordinates())]
         data.extend((self.seqid, self.biotype, self.name))
         self._id = hash(tuple(data))
+        self._strand = strand
 
     def __eq__(self, other):
         return self._id == other._id
@@ -72,7 +81,7 @@ def get_slice(self, complete: bool = False, allow_gaps: bool = False):
         Parameters
         ----------
         complete
-            if feature not complete on parent,causes an exception to be
+            if feature not complete on parent, causes an exception to be
             raised. If False, gaps are removed.
         allow_gaps
             if on an alignment, includes the gap positions
@@ -86,13 +95,20 @@ def get_slice(self, complete: bool = False, allow_gaps: bool = False):
         If 'complete' is true and the full length of this feature is not
         present in the sequence, then this method will fail.
         """
-        # todo gah set allow_gaps=True as the default
-        map = self.map
-        if not (complete or map.complete):
-            map = map.without_gaps()
+        fmap = self.map
+        if not (complete or fmap.complete):
+            fmap = fmap.without_gaps()
         if not allow_gaps:
-            return self.parent[map]
-        return self.parent[map.start : map.end]
+            result = self.parent[fmap]
+            if self.reversed:
+                result = result.rc()
+            return result
+
+        # all slicing now requires start < end
+        result = self.parent[fmap.start : fmap.end]
+        if self.reversed:
+            result = result.rc()
+        return result
 
     def without_lost_spans(self):
         """Keeps only the parts which are actually present in the underlying sequence"""
@@ -134,6 +150,11 @@ def __repr__(self):
         return f"{name}({txt})"
 
     def remapped_to(self, grandparent, gmap):
+        # grandparent can be either a Sequence or an Alignment
+        if not isinstance(gmap, FeatureMap):
+            # due to separation of IndelMap and Map, change class
+            gmap = gmap.to_feature_map()
+
         seqid = grandparent.name or f"from {self.seqid!r}"
         kwargs = {
             **self._serialisable,
@@ -150,7 +171,7 @@ def get_children(self, biotype: Optional[str] = None, **kwargs):
         """generator returns sub-features of self optionally matching biotype"""
         offset = getattr(self.parent, "annotation_offset", 0)
         start = self.map.start + offset
-        end = self.map.end + offset
+        stop = self.map.end + offset
 
         make_feature = self.parent.make_feature
         db = self.parent.annotation_db
@@ -158,7 +179,7 @@ def get_children(self, biotype: Optional[str] = None, **kwargs):
             biotype=biotype,
             name=self.name,
             start=start,
-            end=end,
+            stop=stop,
             **kwargs,
         ):
             record["spans"] = array(record["spans"]) - offset
@@ -174,14 +195,14 @@ def get_parent(self, **kwargs):
         """generator returns parent features of self optionally matching biotype"""
         offset = getattr(self.parent, "annotation_offset", 0)
         start = self.map.start + offset
-        end = self.map.end + offset
+        stop = self.map.end + offset
 
         make_feature = self.parent.make_feature
         db = self.parent.annotation_db
         for record in db.get_feature_parent(
             name=self.name,
             start=start,
-            end=end,
+            stop=stop,
             **kwargs,
         ):
             record["spans"] = array(record["spans"]) - offset
@@ -200,13 +221,20 @@ def union(self, features: Iterable):
         -----
         Overlapping spans are merged
         """
-        combined = self.map.spans[:]
+        # spans always on the plus strand, irrespective of whether
+        # a feature is reversed
+        combined = list(self.map.spans)
         feat_names = [self.name] if self.name else set()
         biotypes = {self.biotype} if self.biotype else set()
         seqids = {self.seqid} if self.seqid else set()
+
+        same_orientation = True
         for feature in features:
             if feature.parent is not self.parent:
-                raise ValueError(f"cannot merge annotations from different objects")
+                raise ValueError("cannot merge annotations from different objects")
+
+            if same_orientation and feature.reversed != self.reversed:
+                same_orientation = False
 
             combined.extend(feature.map.spans)
             if feature.name:
@@ -215,19 +243,25 @@ def union(self, features: Iterable):
                 seqids.add(feature.seqid)
             if feature.biotype:
                 biotypes.add(feature.biotype)
+
         name = ", ".join(feat_names)
-        map = Map(spans=combined, parent_length=len(self.parent))
-        map = map.covered()  # No overlaps
+        fmap = FeatureMap(spans=combined, parent_length=len(self.parent))
+        fmap = fmap.covered()  # No overlaps
         # the covered method drops reversed status so we need to
         # resurrect that, but noting we've not checked consistency
         # across the features
-        if self.map.reverse != map.reverse:
-            map = map.reversed()
+        strand = self._strand if same_orientation else "+"
         seqid = ", ".join(seqids) if seqids else None
         biotype = ", ".join(biotypes)
         kwargs = {
             **self._serialisable,
-            **{"map": map, "seqid": seqid, "biotype": biotype, "name": name},
+            **{
+                "map": fmap,
+                "seqid": seqid,
+                "biotype": biotype,
+                "name": name,
+                "strand": strand,
+            },
         }
 
         return self.__class__(**kwargs)
@@ -244,9 +278,13 @@ def to_dict(self):
             **self._serialisable,
             **dict(
                 spans=self.map.get_coordinates(),
-                strand="-" if self.map.reverse else "+",
             ),
         }
         for key in ("map", "parent"):
             result.pop(key, None)
         return result
+
+    @property
+    def reversed(self):
+        """whether Feature is on the reverse strand relative to bound object"""
+        return self._strand == "-"
diff --git a/src/cogent3/core/annotation_db.py b/src/cogent3/core/annotation_db.py
index 641e78ed0..d8cd86554 100644
--- a/src/cogent3/core/annotation_db.py
+++ b/src/cogent3/core/annotation_db.py
@@ -3,6 +3,7 @@
 import collections
 import copy
 import inspect
+import io
 import json
 import os
 import pathlib
@@ -10,10 +11,13 @@
 import sqlite3
 import sys
 import typing
+import warnings
 
 import numpy
 import typing_extensions
 
+import cogent3.util.warning as c3warn
+
 from cogent3._version import __version__
 from cogent3.util.deserialise import register_deserialiser
 from cogent3.util.misc import extend_docstring_from, get_object_provenance
@@ -36,14 +40,35 @@
 
 # Define custom types for storage in sqlite
 # https://stackoverflow.com/questions/18621513/python-insert-numpy-array-into-sqlite3-database
-def array_to_sqlite(data):
-    return data.tobytes()
-
+def array_to_sqlite(data: numpy.ndarray) -> bytes:
+    with io.BytesIO() as out:
+        numpy.save(out, data)
+        out.seek(0)
+        output = out.read()
+    return output
+
+
+def sqlite_to_array(data: bytes) -> numpy.ndarray:
+    with io.BytesIO(data) as out:
+        out.seek(0)
+        try:
+            result = numpy.load(out)
+        except ValueError:
+            # array is not stored in the numpy.save format
+            # attempt to read from the old format where the
+            # array was saved using numpy.ndarray.tobytes
+            result = numpy.frombuffer(data, dtype=int)
+            dim = result.shape[0] // 2
+            result = result.reshape((dim, 2))
+
+            warnings.warn(
+                "Old OS dependent database file format detected. "
+                "Update the file format using cogent3.core.annotation_db.update_file_format() "
+                "For reason see https://github.com/cogent3/cogent3/issues/1776.",
+                UserWarning,
+            )
 
-def sqlite_to_array(data):
-    result = numpy.frombuffer(data, dtype=int)
-    dim = result.shape[0] // 2
-    return result.reshape((dim, 2))
+    return result
 
 
 def dict_to_sqlite_as_json(data: dict) -> str:
@@ -66,25 +91,27 @@ class FeatureDataType(typing.TypedDict):
     biotype: str  # rename type attr of cogent3 Annotatables to match this?
     name: str  # rename to name to match cogent3 Annotatable.name?
     spans: list[tuple[int, int]]
-    reversed: bool  # True if feature on reverse strand
+    strand: str  # "-" if feature on reverse strand
     on_alignment: bool  # True if feature on an alignment
 
 
 @typing.runtime_checkable
 class SerialisableType(typing.Protocol):  # pragma: no cover
-    def to_rich_dict(self) -> dict:
-        ...
+    def to_rich_dict(self) -> dict: ...
 
-    def to_json(self) -> str:
-        ...
+    def to_json(self) -> str: ...
 
-    def from_dict(self, data):
-        ...
+    def from_dict(self, data): ...
 
 
 @typing.runtime_checkable
 class SupportsQueryFeatures(typing.Protocol):  # pragma: no cover
     # should be defined centrally
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_features_matching(
         self,
         *,
@@ -92,22 +119,39 @@ def get_features_matching(
         biotype: OptionalStr = None,
         name: OptionalStr = None,
         start: OptionalInt = None,
-        end: OptionalInt = None,
+        stop: OptionalInt = None,
         strand: OptionalStr = None,
         attributes: OptionalStr = None,
         on_alignment: OptionalBool = None,
-    ) -> typing.Iterator[FeatureDataType]:
-        ...
+    ) -> typing.Iterator[FeatureDataType]: ...
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_feature_children(
-        self, *, name: str, start: OptionalInt = None, end: OptionalInt = None, **kwargs
-    ) -> typing.List[FeatureDataType]:
-        ...
-
+        self,
+        *,
+        name: str,
+        start: OptionalInt = None,
+        stop: OptionalInt = None,
+        **kwargs,
+    ) -> typing.List[FeatureDataType]: ...
+
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_feature_parent(
-        self, *, name: str, start: OptionalInt = None, end: OptionalInt = None, **kwargs
-    ) -> typing.List[FeatureDataType]:
-        ...
+        self,
+        *,
+        name: str,
+        start: OptionalInt = None,
+        stop: OptionalInt = None,
+        **kwargs,
+    ) -> typing.List[FeatureDataType]: ...
 
     def num_matches(
         self,
@@ -118,9 +162,13 @@ def num_matches(
         strand: OptionalStr = None,
         attributes: OptionalStr = None,
         on_alignment: OptionalBool = None,
-    ) -> int:
-        ...
+    ) -> int: ...
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def subset(
         self,
         *,
@@ -128,11 +176,10 @@ def subset(
         biotype: OptionalStr = None,
         name: OptionalStr = None,
         start: OptionalInt = None,
-        end: OptionalInt = None,
+        stop: OptionalInt = None,
         strand: OptionalStr = None,
         attributes: OptionalStr = None,
-    ):
-        ...
+    ): ...
 
 
 @typing.runtime_checkable
@@ -149,8 +196,7 @@ def add_feature(
         attributes: OptionalStr = None,
         strand: OptionalStr = None,
         on_alignment: bool = False,
-    ) -> None:
-        ...
+    ) -> None: ...
 
     def add_records(
         self,
@@ -158,8 +204,7 @@ def add_records(
         records: typing.Sequence[dict],
         # seqid required for genbank
         seqid: OptionalStr = None,
-    ) -> None:
-        ...
+    ) -> None: ...
 
     def update(self, annot_db, seqids: OptionalStrList = None) -> None:
         # update records with those from an instance of the same type
@@ -247,19 +292,16 @@ def _matching_conditions(
     conditions : dict
         column name and values to be matched
     allow_partial : bool, optional
-        if False, only records within start, end are included. If True,
-        all records that overlap the segment defined by start, end are included.
+        if False, only records within start, stop are included. If True,
+        all records that overlap the segment defined by start, stop are included.
 
     Returns
     -------
     str, tuple
         the SQL statement and the tuple of values
     """
-    # todo this needs to support OR operation on some conditions, e.g. if the value of
-    # a condition is a tuple, do OR
-
     start = conditions.pop("start", None)
-    end = conditions.pop("end", None)
+    stop = conditions.pop("stop", None)
 
     sql = []
     vals = ()
@@ -281,27 +323,27 @@ def _matching_conditions(
         sql.append(" AND ".join(conds))
         vals = tuple(vals)
 
-    if isinstance(start, int) and isinstance(end, int):
+    if start is not None and stop is not None:
         if allow_partial:
             # allow matches that overlap the segment
             cond = [
-                f"(start >= {start} AND end <= {end})",  # lies within the segment
-                f"(start <= {start} AND end > {start})",  # straddles beginning of segment
-                f"(start < {end} AND end >= {end})",  # straddles end of segment
-                f"(start <= {start} AND end >= {end})",  # includes segment
+                f"(start >= {start} AND stop <= {stop})",  # lies within the segment
+                f"(start <= {start} AND stop > {start})",  # straddles beginning of segment
+                f"(start < {stop} AND stop >= {stop})",  # straddles stop of segment
+                f"(start <= {start} AND stop >= {stop})",  # includes segment
             ]
             cond = " OR ".join(cond)
         else:
             # only matches within bounds
-            cond = f"start >= {start} AND end <= {end}"
+            cond = f"start >= {start} AND stop <= {stop}"
         sql.append(f"({cond})")
-    elif isinstance(start, int):
-        # if query has no end, then any feature containing start
-        cond = f"(start <= {start} AND {start} < end)"
+    elif start is not None:
+        # if query has no stop, then any feature containing start
+        cond = f"(start <= {start} AND {start} < stop)"
         sql.append(f"({cond})")
-    elif isinstance(end, int):
-        # if query has no start, then any feature containing end
-        cond = f"(start <= {end} AND {end} < end)"
+    elif stop is not None:
+        # if query has no start, then any feature containing stop
+        cond = f"(start <= {stop} AND {stop} < stop)"
         sql.append(f"({cond})")
 
     sql = f"{' AND '.join(sql)}"
@@ -312,7 +354,7 @@ def _del_records_sql(
     table_name: str,
     conditions: dict,
     start: OptionalInt = None,
-    end: OptionalInt = None,
+    stop: OptionalInt = None,
     allow_partial=True,
 ) -> ReturnType:
     """creates the SQL and values for identifying records to be deleted
@@ -323,12 +365,12 @@ def _del_records_sql(
         table to have records deleted from
     conditions : dict
         column name and values to be matched
-    start, end : OptionalInt
-        select records whose (start, end) values lie between start and end,
+    start, stop : OptionalInt
+        select records whose (start, stop) values lie between start and stop,
         or overlap them if (allow_partial is True)
     allow_partial : bool, optional
-        if False, only records within start, end are included. If True,
-        all records that overlap the segment defined by start, end are included.
+        if False, only records within start, stop are included. If True,
+        all records that overlap the segment defined by start, stop are included.
 
     Returns
     -------
@@ -336,7 +378,7 @@ def _del_records_sql(
         the SQL statement and the tuple of values
     """
     where, vals = _matching_conditions(
-        conditions=conditions, start=start, end=end, allow_partial=allow_partial
+        conditions=conditions, start=start, stop=stop, allow_partial=allow_partial
     )
     sql = f"DELETE FROM {table_name}"
     if not where:
@@ -351,7 +393,7 @@ def _select_records_sql(
     conditions: dict,
     columns: typing.Optional[typing.List[str]] = None,
     start: OptionalInt = None,
-    end: OptionalInt = None,
+    stop: OptionalInt = None,
     allow_partial=True,
 ) -> ReturnType:
     """create SQL select statement and values
@@ -364,12 +406,12 @@ def _select_records_sql(
         values to select
     conditions : dict
         the WHERE conditions
-    start, end : OptionalInt
-        select records whose (start, end) values lie between start and end,
+    start, stop : OptionalInt
+        select records whose (start, stop) values lie between start and stop,
         or overlap them if (allow_partial is True)
     allow_partial : bool, optional
-        if False, only records within start, end are included. If True,
-        all records that overlap the segment defined by start, end are included.
+        if False, only records within start, stop are included. If True,
+        all records that overlap the segment defined by start, stop are included.
 
     Returns
     -------
@@ -394,7 +436,7 @@ def _count_records_sql(
     conditions: dict,
     columns: typing.Optional[typing.List[str]] = None,
     start: OptionalInt = None,
-    end: OptionalInt = None,
+    stop: OptionalInt = None,
     allow_partial=True,
 ) -> ReturnType:
     """create SQL count statement and values
@@ -407,12 +449,12 @@ def _count_records_sql(
         values to select
     conditions : dict
         the WHERE conditions
-    start, end : OptionalInt
-        select records whose (start, end) values lie between start and end,
+    start, stop : OptionalInt
+        select records whose (start, stop) values lie between start and stop,
         or overlap them if (allow_partial is True)
     allow_partial : bool, optional
-        if False, only records within start, end are included. If True,
-        all records that overlap the segment defined by start, end are included.
+        if False, only records within start, stop are included. If True,
+        all records that overlap the segment defined by start, stop are included.
 
     Returns
     -------
@@ -451,6 +493,38 @@ def _compatible_schema(db: sqlite3.Connection, schema: dict[str, set]) -> bool:
     return True
 
 
+def _rename_column_if_exists(
+    db: sqlite3.Connection, table_name: str, old_column: str, new_column: str
+) -> None:
+    """Rename a column in a sqlite3 database only if it exists.
+
+    Parameters
+    ----------
+    db : sqlite3.Connection
+        the sqlite3 connection
+    table_name : str
+        the table name
+    old_column : str
+        the column to rename if it exists
+    new_column : str
+        the new name of old_column
+    """
+    cur = db.cursor()
+    table_info = cur.execute(f"PRAGMA table_info({table_name});").fetchall()
+
+    # Check if the column exists
+    for column_info in table_info:
+        if column_info["name"] == old_column:
+            break
+    else:
+        return  # There is no column to rename
+
+    sql = f'ALTER TABLE {table_name} RENAME COLUMN "{old_column}" TO {new_column}'
+
+    cur.execute(sql)
+    db.commit()
+
+
 class SqliteAnnotationDbMixin:
     # table schema for user provided annotations
     _user_schema = {
@@ -459,7 +533,7 @@ class SqliteAnnotationDbMixin:
         "name": "TEXT",
         "parent_id": "TEXT",
         "start": "INTEGER",
-        "end": "INTEGER",
+        "stop": "INTEGER",
         "strand": "TEXT",
         "spans": "array",
         "attributes": "TEXT",
@@ -581,6 +655,9 @@ def db(self) -> sqlite3.Connection:
             )
             self._db.row_factory = sqlite3.Row
 
+            for table_name in self.table_names:
+                _rename_column_if_exists(self._db, table_name, "end", "stop")
+
         return self._db
 
     def _execute_sql(self, cmnd, values=None):
@@ -622,10 +699,10 @@ def add_feature(
             whether the annotation is an alignment annotation
         """
         spans = numpy.array(sorted(sorted(coords) for coords in spans), dtype=int)
-        # the start, end variables are added into the record via the loop
+        # the start, stop variables are added into the record via the loop
         # on local variables
         start = int(spans.min())
-        end = int(spans.max())
+        stop = int(spans.max())
         # we define record as all defined variables from local name space,
         # excluding "self"
         record = {k: v for k, v in locals().items() if k != "self" and v is not None}
@@ -655,7 +732,7 @@ def _get_feature_by_id(
         column: str,
         name: str,
         start: OptionalInt = None,
-        end: OptionalInt = None,
+        stop: OptionalInt = None,
         biotype: OptionalStr = None,
         allow_partial: bool = False,
     ) -> typing.List[FeatureDataType]:
@@ -665,20 +742,20 @@ def _get_feature_by_id(
             conditions={column: name, "biotype": biotype},
             columns=columns,
             start=start,
-            end=end,
+            stop=stop,
+            allow_partial=allow_partial,
         )
         for result in self._execute_sql(sql, values=vals):
             result = dict(zip(result.keys(), result))
             result["on_alignment"] = result.get("on_alignment")
             result["spans"] = [tuple(c) for c in result["spans"]]
-            result["reversed"] = result.pop("strand", None) == "-"
             yield result
 
     def get_feature_children(
         self, name: str, biotype: OptionalStr = None, **kwargs
     ) -> typing.Iterator[FeatureDataType]:
         """yields children of name"""
-        # kwargs is used because other classes need start / end
+        # kwargs is used because other classes need start / stop
         # just uses search matching
         for table_name in self.table_names:
             columns = "seqid", "biotype", "spans", "strand", "name", "parent_id"
@@ -730,6 +807,11 @@ def get_feature_parent(
                         )  # remove invalid field for the FeatureDataType
                         yield parent
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_records_matching(
         self,
         *,
@@ -737,8 +819,8 @@ def get_records_matching(
         seqid: str = None,
         name: str = None,
         start: int = None,
-        end: int = None,
-        strand: bool = None,
+        stop: int = None,
+        strand: OptionalStr = None,
         attributes: OptionalStr = None,
         on_alignment: bool = None,
         allow_partial: bool = False,
@@ -754,6 +836,11 @@ def get_records_matching(
             for result in self._get_records_matching(table_name, **kwargs):
                 yield dict(zip(result.keys(), result))
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_features_matching(
         self,
         *,
@@ -761,8 +848,8 @@ def get_features_matching(
         seqid: str = None,
         name: str = None,
         start: int = None,
-        end: int = None,
-        strand: bool = None,
+        stop: int = None,
+        strand: OptionalStr = None,
         attributes: OptionalStr = None,
         on_alignment: bool = None,
         allow_partial: bool = False,
@@ -788,7 +875,6 @@ def get_features_matching(
                 result = dict(zip(result.keys(), result))
                 result["on_alignment"] = result.get("on_alignment")
                 result["spans"] = [tuple(c) for c in result["spans"]]
-                result["reversed"] = result.pop("strand", None) == "-"
                 yield result
 
     def num_matches(
@@ -1045,6 +1131,11 @@ def write(self, path: os.PathLike) -> None:
             self.db.backup(backup)
         backup.close()
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def subset(
         self,
         *,
@@ -1052,13 +1143,17 @@ def subset(
         biotype: str = None,
         seqid: str = None,
         name: str = None,
-        start: int = None,
-        end: int = None,
-        strand: bool = None,
+        start: OptionalInt = None,
+        stop: OptionalInt = None,
+        strand: OptionalStr = None,
         attributes: OptionalStr = None,
         allow_partial: bool = False,
     ) -> typing_extensions.Self:
         """returns a new db instance with records matching the provided conditions"""
+        # make sure python, not numpy, integers
+        start = start if start is None else int(start)
+        stop = stop if stop is None else int(stop)
+
         kwargs = {k: v for k, v in locals().items() if k not in {"self", "source"}}
 
         result = self.__class__(source=source)
@@ -1143,7 +1238,7 @@ class GffAnnotationDb(SqliteAnnotationDbMixin):
         "source": "TEXT",
         "biotype": "TEXT",  # type in GFF
         "start": "INTEGER",
-        "end": "INTEGER",
+        "stop": "INTEGER",
         "score": "TEXT",  # check defn
         "strand": "TEXT",
         "phase": "TEXT",
@@ -1182,12 +1277,12 @@ def add_records(self, reduced: dict) -> None:
         # required
         rows = []
         for record in reduced.values():
-            # our Feature code assumes start always < end,
+            # our Feature code assumes start always < stop,
             # we record direction using Strand
             spans = numpy.array(sorted(record["spans"]), dtype=int)  # sorts the rows
             spans.sort(axis=1)
             record["start"] = int(spans.min())
-            record["end"] = int(spans.max())
+            record["stop"] = int(spans.max())
             record["spans"] = spans
             rows.append(tuple(record.get(c) for c in col_order))
 
@@ -1211,6 +1306,7 @@ def _merged_data(self, records) -> dict:
         # record.
         for record in records:
             record["biotype"] = record.pop("Type")
+            record["stop"] = record.pop("End")
 
             # we force all keys that map to table column names to be lower case
             for key in tuple(record):
@@ -1234,7 +1330,7 @@ def _merged_data(self, records) -> dict:
                 reduced[record_id]["spans"] = []
 
             # should this just be an append?
-            reduced[record_id]["spans"].append((record["start"], record["end"]))
+            reduced[record_id]["spans"].append((record["start"], record["stop"]))
 
         del records
 
@@ -1267,7 +1363,7 @@ class GenbankAnnotationDb(SqliteAnnotationDbMixin):
         "source": "TEXT",
         "biotype": "TEXT",  # type in GFF
         "start": "INTEGER",
-        "end": "INTEGER",
+        "stop": "INTEGER",
         "strand": "TEXT",
         "comments": "TEXT",
         "spans": "array",  # aggregation of coords across records
@@ -1318,7 +1414,7 @@ def add_records(self, records, seqid):
         rows = []
         exclude = {"translation", "location", "type"}  # location is grabbed directly
         for record in records:
-            # our Feature code assumes start always < end,
+            # our Feature code assumes start always < stop,
             store = {"seqid": seqid}
             # we create the location data directly
             if location := record.get("location", None):
@@ -1326,7 +1422,7 @@ def add_records(self, records, seqid):
                 if strand := location.strand:
                     store["strand"] = "-" if strand == -1 else "+"
                 store["start"] = int(store["spans"].min())
-                store["end"] = int(store["spans"].max())
+                store["stop"] = int(store["spans"].max())
 
             attrs_keys = record.keys() - exclude
             store["biotype"] = record["type"]
@@ -1367,17 +1463,22 @@ def _make_fake_id(self, record: dict) -> typing.List[str]:
         self._num_fakeids += 1
         return name
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_feature_children(
         self,
         name: str,
         biotype: OptionalStr = None,
         exclude_biotype: OptionalStr = None,
         start: OptionalInt = None,
-        end: OptionalInt = None,
+        stop: OptionalInt = None,
     ) -> typing.Iterator[FeatureDataType]:
         """yields children of name"""
         # children must lie within the parent coordinates
-        if start is None or end is None:
+        if start is None or stop is None:
             name = self.__class__.__name__
             msg = f"coordinates required to query children for {name!r}"
             raise ValueError(msg)
@@ -1391,7 +1492,7 @@ def get_feature_children(
                 "name",
                 "parent_id",
                 "start",
-                "end",
+                "stop",
             )
             if table_name == "user":
                 columns += ("on_alignment",)
@@ -1402,28 +1503,33 @@ def get_feature_children(
                 column="name",
                 name=name,
                 biotype=biotype,
-                start=int(start),
-                end=int(end),
+                start=start,
+                stop=stop,
                 allow_partial=False,
             ):
                 if feat["biotype"] == exclude_biotype:
                     continue
-                cstart, cend = feat.pop("start"), feat.pop("end")
-                if not (start <= cstart < end and start < cend <= end):
+                cstart, cstop = feat.pop("start"), feat.pop("stop")
+                if not (start <= cstart < stop and start < cstop <= stop):
                     continue
 
                 feat.pop("parent_id")  # remove invalid field for the FeatureDataType
                 yield feat
 
+    @c3warn.deprecated_args(
+        "2024.9",
+        reason="replace usage of SQL keyword as column name",
+        old_new=[("end", "stop")],
+    )
     def get_feature_parent(
         self,
         name: str,
         exclude_biotype: OptionalStr = None,
         start: OptionalInt = None,
-        end: OptionalInt = None,
+        stop: OptionalInt = None,
     ) -> typing.Iterator[FeatureDataType]:
         """yields parents of name"""
-        if start is None or end is None:
+        if start is None or stop is None:
             name = self.__class__.__name__
             msg = f"coordinates required to query parent for {name!r}"
             raise ValueError(msg)
@@ -1438,7 +1544,7 @@ def get_feature_parent(
                 "name",
                 "parent_id",
                 "start",
-                "end",
+                "stop",
             )
             if table_name == "user":
                 columns += ("on_alignment",)
@@ -1447,16 +1553,16 @@ def get_feature_parent(
                 columns=columns,
                 column="name",
                 name=name,
-                start=int(start),
-                end=int(end),
+                start=start,
+                stop=stop,
                 allow_partial=False,
             ):
                 # add support for != operation to SQL where clause generation
                 if feat["biotype"] == exclude_biotype:
                     continue
 
-                cstart, cend = feat.pop("start"), feat.pop("end")
-                if cstart > start or end > cend:
+                cstart, cstop = feat.pop("start"), feat.pop("stop")
+                if cstart > start or stop > cstop:
                     continue
 
                 feat.pop("parent_id")  # remove invalid field for the FeatureDataType
@@ -1609,3 +1715,96 @@ def load_annotations(
             show_progress=show_progress,
         )
     )
+
+
+def _update_array_format(data: bytes) -> bytes:
+    """Convert from the old to the new sqlite numpy array format.
+
+    Converts the previous format saved with numpy.ndarray.tobytes
+    to the .npy format generated with numpy.save.
+
+    Ignores any entries saved in the new format
+
+    Parameters
+    ----------
+    data : bytes
+        The sqlite3 representation of the numpy array.
+
+    Returns
+    -------
+    bytes
+        The new sqlite3 representation of the numpy array.
+    """
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        new_data = array_to_sqlite(sqlite_to_array(data))
+    return new_data
+
+
+def update_file_format(
+    source_path: os.PathLike,
+    db_class: typing.Union[
+        type[BasicAnnotationDb],
+        type[GenbankAnnotationDb],
+        type[GffAnnotationDb],
+    ],
+    backup: bool = True,
+) -> None:
+    """Update the database file to the latest format.
+
+    Fixes an OS dependent issue under the previous representation.
+
+    Ensure update_file_format is run on the same OS used to generate the
+    database file.
+
+    By default performs a backup of the database to another file before
+    updating the given file to the new format. This is in case the function
+    is run on a different OS than the one used to generate the file where
+    the spans column may become corrupted. The backup file is saved as the
+    original file path appended with ".bak".
+
+    See https://github.com/cogent3/cogent3/issues/1776 for more details.
+
+    Parameters
+    ----------
+    source_path : os.PathLike
+        The database file to reformat.
+    db_class : typing.Union[ type[BasicAnnotationDb], type[GenbankAnnotationDb], type[GffAnnotationDb], ]
+        The type of database the file is.
+    backup : bool, optional
+        If True (default), performs a backup of the database before updating.
+        Otherwise does not perform a backup prior to update (not recommended).
+    """
+    source_path = pathlib.Path(source_path).expanduser()
+
+    if not source_path.exists():
+        raise OSError(f"File {source_path} does not exist.")
+
+    anno_db = db_class(source=source_path)
+
+    if backup:
+        backup_path = source_path.parent / f"{source_path.name}.bak"
+        if backup_path.exists():
+            raise FileExistsError(
+                f"Backup file already exists for {source_path}. "
+                f"If there was a problem with the conversion process, "
+                f"update_file_format should be run on the backed up file. "
+                f"Ensure update_file_format is run on the same OS used to generate the file."
+            )
+        anno_db.write(backup_path)
+
+    conn = anno_db.db
+    conn.create_function("update_array_format", 1, _update_array_format)
+    for table_name in anno_db.table_names:
+        cursor = conn.cursor()
+        array_columns = [
+            r["name"]
+            for r in cursor.execute(f"PRAGMA table_info({table_name});").fetchall()
+            if r["type"] == "array"
+        ]
+
+        for column in array_columns:
+            cursor.execute(
+                f"UPDATE {table_name} SET {column}=update_array_format({column});"
+            )
+        conn.commit()
diff --git a/src/cogent3/core/location.py b/src/cogent3/core/location.py
index c157eb73d..43e6501da 100644
--- a/src/cogent3/core/location.py
+++ b/src/cogent3/core/location.py
@@ -1,35 +1,22 @@
-"""
-Maps have a list of Spans.
-
-Span is a region with a start, an end, and a direction.
-
-Notes
------
-
-Spans behave much like Python's slices: a Span contains the element
-after its start but does not contain the element after its end. It may help to
-think of the Span indices occurring _between_ the list elements:
-
-    a b c d e
-   | | | | | |
-   0 1 2 3 4 5
-
-...so that a Span whose start is its end contains no elements (e.g. 2:2), and
-a Span whose end is 2 more than its start contains 2 elements (e.g. 2:4 has c
-and d), etc. Similarly, Span(0,2) does _not_ overlap Span(2,3), since the
-former contains a and b while the latter contains c.
-"""
 import copy
+import dataclasses
+import functools
+import inspect
+import json
 
+from abc import ABC, abstractmethod
 from bisect import bisect_left, bisect_right
 from functools import total_ordering
 from itertools import chain
-from typing import Union
+from typing import Any, Iterator, Optional, Sequence, Union
+
+import numpy
 
-from numpy import array, ndarray
+from numpy.typing import NDArray
 
 from cogent3._version import __version__
 from cogent3.util import warning as c3warn
+from cogent3.util.deserialise import register_deserialiser
 from cogent3.util.misc import (
     ClassChecker,
     ConstrainedList,
@@ -41,6 +28,8 @@
 
 strip = str.strip
 
+_DEFAULT_GAP_DTYPE = numpy.int32
+
 
 def _norm_index(i, length, default):
     """For converting s[:3] to s[0:3], s[-1] to s[len(s)-1] and s[0:lots] to s[0:len(s)]"""
@@ -51,40 +40,25 @@ def _norm_index(i, length, default):
     return min(max(i, 0), length)
 
 
-def _norm_slice(index, length):
-    """_norm_slice(slice(1, -2, 3), 10) -> (1,8,3)"""
-    if isinstance(index, slice):
-        start = _norm_index(index.start, length, 0)
-        end = _norm_index(index.stop, length, length)
-        return (start, end, index.step)
-    else:
-        start = index
-        if start < 0:
-            start += length
-        if start >= length:
-            raise IndexError(index)
-        return (start, start + 1, 1)
-
-
-def as_map(slice, length):
+def as_map(slice, length, cls):
     """Take anything that might be used as a subscript: Integer, Slice,
-    or Map, and return a Map."""
+    or MapABC, and return cls."""
 
     if isinstance(slice, (list, tuple)):
         spans = []
         for i in slice:
-            spans.extend(as_map(i, length).spans)
-        map = Map(spans=spans, parent_length=length)
-    elif isinstance(slice, Map):
-        map = slice
-        # TODO reasons for failure when the following is not commented out
-        # should be checked further
-        # assert map.parent_length == length, (map, length)
+            spans.extend(as_map(i, length, cls).spans)
+        return cls(spans=spans, parent_length=length)
+    elif isinstance(slice, (FeatureMap, IndelMap, Map)):
+        return slice
     else:
-        (lo, hi, step) = _norm_slice(slice, length)
+        lo, hi, step = _norm_slice(slice, length)
         assert (step or 1) == 1
-        map = Map([(lo, hi)], parent_length=length)
-    return map
+        # since we disallow step, a reverse slice means an empty series
+        locations = [] if lo > hi else [(lo, hi)]
+        # IndelMap has this class method
+        cls = getattr(cls, "from_locations", cls)
+        return cls(locations=locations, parent_length=length)
 
 
 class SpanI(object):
@@ -307,7 +281,7 @@ def __mul__(self, scale):
             self.reverse,
         )
 
-    def __div__(self, scale):
+    def __truediv__(self, scale: int):
         assert not self.start % scale or self.end % scale
         return Span(
             self.start // scale,
@@ -323,13 +297,13 @@ def remap_with(self, map):
         C is a span of a feature on B which itself is a feature on A, so to
         place C on A return that part of B (map) covered by C (self)"""
 
-        (offsets, spans) = (map.offsets, map.spans)
+        offsets, spans = map.offsets, list(map.spans)
         map_length = offsets[-1] + spans[-1].length
 
         # don't try to remap any non-corresponding end region(s)
         # this won't matter if all spans lie properly within their
         # parent maps, but that might not be true of Display slices.
-        (zlo, zhi) = (max(0, self.start), min(map_length, self.end))
+        zlo, zhi = max(0, self.start), min(map_length, self.end)
 
         # Find the right span(s) of the map
         first = bisect_right(offsets, zlo) - 1
@@ -450,7 +424,7 @@ def __eq__(self, other):
             return type(self) == type(other)
 
 
-class _LostSpan(object):
+class _LostSpan:
     """A placeholder span which doesn't exist in the underlying sequence"""
 
     __slots__ = ["length", "value", "_serialisable"]
@@ -497,7 +471,7 @@ def __getitem__(self, slice):
     def __mul__(self, scale):
         return LostSpan(self.length * scale, self.value)
 
-    def __div__(self, scale):
+    def __truediv__(self, scale):
         assert not self.length % 3
         return LostSpan(self.length // scale, self.value)
 
@@ -529,9 +503,21 @@ def __repr__(self):
         return f"?{self.length}?"
 
 
-class Map(object):
+IntTypes = Union[int, numpy.int32, numpy.int64]
+IntArrayTypes = NDArray[int]
+SpanTypes = Union[Span, _LostSpan]
+SeqSpanTypes = Sequence[SpanTypes]
+SeqCoordTypes = Sequence[Sequence[IntTypes]]
+
+
+class Map:  # pragma: no cover
     """A map holds a list of spans."""
 
+    @c3warn.deprecated_callable(
+        version="2024.6",
+        reason="Replaced by IndelMap and FeatureMap",
+        is_discontinued=True,
+    )
     def __init__(
         self,
         locations=None,
@@ -552,7 +538,7 @@ def __init__(
                 reverse = start > end
                 if max(start, end) < 0 or min(start, end) > parent_length:
                     raise RuntimeError(
-                        f"located outside sequence: {str((start, end, parent_length))}"
+                        f"located outside sequence: {(start, end, parent_length)}"
                     )
                 if max(start, end) > parent_length and min(start, end) < 0:
                     l_diff = min(start, end)
@@ -605,12 +591,13 @@ def __init__(
                     self.reverse = None
 
         if termini_unknown:
+            spans = list(spans)
             if spans[0].lost:
                 spans[0] = TerminalPadding(spans[0].length)
             if spans[-1].lost:
                 spans[-1] = TerminalPadding(spans[-1].length)
 
-        self.spans = spans
+        self.spans = tuple(spans)
         self.length = posn
         self.parent_length = parent_length
         self.__inverse = None
@@ -619,11 +606,11 @@ def __len__(self):
         return self.length
 
     def __repr__(self):
-        return repr(self.spans) + f"/{self.parent_length}"
+        return repr(list(self.spans)) + f"/{self.parent_length}"
 
     def __getitem__(self, slice):
         # A possible shorter map at the same level
-        slice = as_map(slice, len(self))
+        slice = as_map(slice, len(self), self.__class__)
         new_parts = []
         for span in slice.spans:
             new_parts.extend(span.remap_with(self))
@@ -800,7 +787,7 @@ def to_rich_dict(self):
         """returns dicts for contained spans [dict(), ..]"""
         spans = [s.to_rich_dict() for s in self.spans]
         data = copy.deepcopy(self._serialisable)
-        data.pop("locations")
+        data.pop("locations", None)
         data["spans"] = spans
         data["type"] = get_object_provenance(self)
         data["version"] = __version__
@@ -823,16 +810,21 @@ def zeroed(self):
         data = self.to_rich_dict()
         zeroed = deserialise_map_spans(data)
         zeroed.parent_length = len(zeroed.get_covering_span())
-        min_val = min(zeroed.start, zeroed.end)
+        shift = min(zeroed.start, zeroed.end)
+        new_end = 0
         for span in zeroed.spans:
             if span.lost:
                 continue
-            span.start -= min_val
-            span.end -= min_val
+            span.start -= shift
+            span.end -= shift
+            new_end = max(new_end, span.end)
+
+        zeroed.start = 0
+        zeroed.end = new_end
 
         return zeroed
 
-    T = Union[ndarray, int]
+    T = Union[numpy.ndarray, int]
 
     def absolute_position(self, rel_pos: T) -> T:
         """converts rel_pos into an absolute position
@@ -841,7 +833,9 @@ def absolute_position(self, rel_pos: T) -> T:
         ------
         raises ValueError if rel_pos < 0
         """
-        check = array([rel_pos], dtype=int) if isinstance(rel_pos, int) else rel_pos
+        check = (
+            numpy.array([rel_pos], dtype=int) if isinstance(rel_pos, int) else rel_pos
+        )
         if check.min() < 0:
             raise ValueError(f"must positive, not {rel_pos=}")
 
@@ -858,243 +852,1090 @@ def relative_position(self, abs_pos: T) -> T:
         ------
         raises ValueError if abs_pos < 0
         """
-        check = array([abs_pos], dtype=int) if isinstance(abs_pos, int) else abs_pos
+        check = (
+            numpy.array([abs_pos], dtype=int) if isinstance(abs_pos, int) else abs_pos
+        )
         if check.min() < 0:
             raise ValueError(f"must positive, not {abs_pos=}")
         return abs_pos - self.start
 
 
-@c3warn.deprecated_callable(
-    version="2024.3", reason="No being used.", is_discontinued=True
-)
-class SpansOnly(ConstrainedList):  # pragma: no cover
-    """List that converts elements to Spans on addition."""
+class MapABC(ABC):
+    """base class for genomic map objects"""
 
-    mask = FunctionWrapper(Span)
-    _constraint = ClassChecker(Span)
+    def __new__(cls, *args, **kwargs):
+        obj = object.__new__(cls)
+        init_sig = inspect.signature(cls.__init__)
+        bargs = init_sig.bind_partial(cls, *args, **kwargs)
+        bargs.apply_defaults()
+        init_vals = bargs.arguments
+        init_vals.pop("self", None)
 
+        obj._serialisable = init_vals
+        return obj
 
-@c3warn.deprecated_callable(
-    version="2024.3", reason="No being used.", is_discontinued=True
-)
-@total_ordering
-class Range(SpanI):  # pragma: no cover
-    """Complex object consisting of many spans."""
-
-    def __init__(self, spans=None):
-        """Returns a new Range object with data in spans."""
-        spans = [] if spans is None else spans
-        result = SpansOnly()
-        # need to check if we got a single Span, since they define __iter__.
-        if isinstance(spans, Span):
-            result.append(spans)
-        elif hasattr(spans, "spans"):  # probably a single range object?
-            result.extend(spans.spans)
-        else:
-            for s in iterable(spans):
-                if hasattr(s, "spans"):
-                    result.extend(s.spans)
-                else:
-                    result.append(s)
-        self.spans = result
+    @abstractmethod
+    def __len__(self) -> int: ...
 
-    def __str__(self):
-        """Returns string representation of self."""
-        return f"({','.join(map(str, self.spans))})"
+    @abstractmethod
+    def __add__(self, other: "MapABC") -> "MapABC": ...
 
-    def __len__(self):
-        """Returns sum of span lengths.
+    @abstractmethod
+    def nongap(self) -> Iterator[SpanTypes]: ...
 
-        NOTE: if spans overlap, will count multiple times. Use reduce() to
-        get rid of overlaps.
-        """
-        return sum(map(len, self.spans))
+    @abstractmethod
+    def get_coordinates(self) -> SeqCoordTypes: ...
 
-    def __lt__(self, other):
-        """Compares spans of self with indices of other."""
-        if hasattr(other, "spans"):
-            return self.spans < other.spans
-        elif len(self.spans) == 1 and hasattr(other, "start") and hasattr(other, "end"):
-            return self.spans[0].start < other.start or self.spans[0].end < other.end
+    @abstractmethod
+    def nucleic_reversed(self) -> "MapABC": ...
+
+    @abstractmethod
+    def to_rich_dict(self) -> dict[str, Any]: ...
+
+    @classmethod
+    def from_locations(
+        cls, locations: SeqCoordTypes, parent_length: int, **kwargs
+    ) -> "MapABC":
+        if len(locations):
+            spans = _spans_from_locations(locations, parent_length=parent_length)
         else:
-            return object < other
+            spans = ()
 
-    def __eq__(self, other):
-        """Compares spans of self with indices of other."""
-        if hasattr(other, "spans"):
-            return self.spans == other.spans
-        elif len(self.spans) == 1 and hasattr(other, "start") and hasattr(other, "end"):
-            return self.spans[0].start == other.start and self.spans[0].end == other.end
+        return cls.from_spans(spans=spans, parent_length=parent_length, **kwargs)
+
+    @classmethod
+    @abstractmethod
+    def from_spans(
+        cls, spans: SeqSpanTypes, parent_length: int, **kwargs
+    ) -> "MapABC": ...
+
+
+def _spans_from_locations(locations: SeqCoordTypes, parent_length: int) -> SeqSpanTypes:
+    if not len(locations):
+        # using len() because locations can be a numpy array
+        return ()
+
+    if locations[0][0] > locations[-1][1]:
+        raise ValueError("locations must be ordered smallest-> largest")
+
+    spans = []
+    for start, end in locations:
+        if start > end or min(start, end) < 0:
+            raise ValueError("locations must be ordered smallest-> largest and >= 0")
+        if start > parent_length:
+            raise RuntimeError(
+                f"located outside sequence: {(start, end, parent_length)}"
+            )
+        if end > parent_length:
+            diff = end - parent_length
+            end = min(end, parent_length)
+            spans += [
+                Span(start, end),
+                LostSpan(abs(diff)),
+            ]
         else:
-            return object == other
+            spans += [Span(start, end)]
 
-    def _get_start(self):
-        """Finds earliest start of items in self.spans."""
-        return min([i.start for i in self.spans])
+    return tuple(spans)
 
-    start = property(_get_start)
 
-    def _get_end(self):
-        """Finds latest end of items in self.spans."""
-        return max([i.end for i in self.spans])
+def spans_to_gap_coords(
+    indel_spans: SeqSpanTypes, dtype: IntTypes = _DEFAULT_GAP_DTYPE
+) -> tuple[IntArrayTypes, IntArrayTypes]:
+    """returns coordinates of sequence gaps
 
-    end = property(_get_end)
+    Parameters
+    ----------
+    indel_spans
+        sequence of Span/LostSpan instances
+    dtype
+        numpy type, default to _DEFAULT_GAP_DTYPE
 
-    def _get_reverse(self):
-        """reverse is True if any piece is reversed."""
-        for i in self.spans:
-            if i.reverse:
-                return True
-        return False
+    Returns
+    -------
+    numpy.array([gap pos,...]), numpy.array([cum gap length,...]),
+    """
+    gap_pos = []
+    cum_lengths = []
+    cum_length = 0
+    for i, span in enumerate(indel_spans):
+        if not span.lost:
+            continue
 
-    reverse = property(_get_reverse)
+        pos = indel_spans[i - 1].end if i else 0
+        cum_length += len(span)
+        gap_pos.append(pos)
+        cum_lengths.append(cum_length)
 
-    def reverses(self):
-        """Reverses all spans in self."""
-        for i in self.spans:
-            i.reverses()
+    return numpy.array(gap_pos, dtype=dtype), numpy.array(cum_lengths, dtype=dtype)
 
-    def __contains__(self, other):
-        """Returns True if other completely contained in self.
 
-        other must either be a number or have start and end properties.
+def _gap_spans(
+    gap_pos: IntArrayTypes, cum_gap_lengths: IntArrayTypes
+) -> tuple[IntArrayTypes, IntArrayTypes]:
+    """returns 1D arrays in alignment coordinates of
+    gap start, gap stop"""
+    if not len(gap_pos):
+        r = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+        return r, r
+
+    ends = gap_pos + cum_gap_lengths
+    starts = gap_pos.copy()
+    starts[1:] += cum_gap_lengths[:-1]
+
+    return starts, ends
+
+
+def _update_lengths(
+    result_pos: IntArrayTypes,
+    result_lengths: IntArrayTypes,
+    gap_pos: IntArrayTypes,
+    gap_lengths: IntArrayTypes,
+) -> None:
+    """modifies result_lengths in place with gap_lengths
+    where elements in gap_pos occur in result_pos
+
+    Notes
+    -----
+    result_pos is a superset of gap_pos
+    """
+    _, result_indices, other_indices = numpy.intersect1d(
+        result_pos, gap_pos, assume_unique=True, return_indices=True
+    )
+    result_lengths[result_indices] += gap_lengths[other_indices]
+
+
+@dataclasses.dataclass
+class IndelMap(MapABC):
+    """store locations of deletions in a Aligned sequence"""
+
+    # gap data is gap positions, gap lengths on input, stored
+    gap_pos: IntArrayTypes
+    cum_gap_lengths: Optional[IntArrayTypes] = None
+    gap_lengths: dataclasses.InitVar[Optional[IntArrayTypes]] = None
+    termini_unknown: bool = False
+    parent_length: int = 0
+    _serialisable: dict = dataclasses.field(init=False, repr=False)
+    num_gaps: int = dataclasses.field(init=False, repr=False, default=0)
+
+    def __post_init__(self, gap_lengths: IntArrayTypes):
+        assert gap_lengths is None or self.cum_gap_lengths is None
+        if gap_lengths is not None:
+            self.cum_gap_lengths = gap_lengths.cumsum()
+
+        if len(self.gap_pos) != len(self.cum_gap_lengths):
+            raise ValueError(
+                f"length of gap_ pos {len(self.gap_pos)} != "
+                f"length of gap lengths {len(self.cum_gap_lengths)}"
+            )
+
+        self.num_gaps = self.gap_pos.shape[0]
+        if self.num_gaps and self.gap_pos[-1] > self.parent_length:
+            raise ValueError(
+                f"gap position {self.gap_pos[-1]} outside parent_length {self.parent_length}"
+            )
+
+        # make gap array immutable
+        self.gap_pos.flags.writeable = False
+        self.cum_gap_lengths.flags.writeable = False
+        self._serialisable.pop("gap_lengths", None)
+
+    @classmethod
+    def from_spans(
+        cls, spans: SeqSpanTypes, parent_length: int, termini_unknown: bool = False
+    ) -> "IndelMap":
+        gap_pos, cum_lengths = spans_to_gap_coords(spans)
+        return cls(
+            gap_pos=gap_pos,
+            cum_gap_lengths=cum_lengths,
+            parent_length=parent_length,
+            termini_unknown=termini_unknown,
+        )
+
+    @classmethod
+    def from_aligned_segments(
+        cls, locations: SeqCoordTypes, aligned_length: int
+    ) -> "IndelMap":
         """
-        if hasattr(other, "spans"):
-            for curr in other.spans:
-                found = False
-                for i in self.spans:
-                    if curr in i:
-                        found = True
-                        break
-                if not found:
-                    return False
-            return True
-        else:
-            for i in self.spans:
-                if other in i:
-                    return True
-            return False
+        converts coordinates from aligned segments into IndelMap for ungapped sequence
+
+        Parameters
+        ----------
+        locations
+            sequence of ungapped segment in alignment coordinates
+        aligned_length
+            length of the alignment
+        """
+        locations = list(locations)
+        if not locations or (
+            len(locations) == 1
+            and locations[0][0] == 0
+            and locations[0][-1] == aligned_length
+        ):
+            empty = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+            return cls(
+                gap_pos=empty,
+                cum_gap_lengths=empty.copy(),
+                parent_length=aligned_length,
+            )
 
-    def overlaps(self, other):
-        """Returns True if any positions in self are also in other."""
-        if hasattr(other, "spans"):
-            for i in self.spans:
-                for j in other.spans:
-                    if i.overlaps(j):
-                        return True
+        if locations[0][0] != 0:
+            # starts with a gap
+            locations = [(0, 0)] + locations
+        if locations[-1][1] < aligned_length:
+            # ends with a gap
+            locations += [(aligned_length, aligned_length)]
+
+        locations = numpy.array(locations, dtype=_DEFAULT_GAP_DTYPE).flatten()[1:-1]
+        gap_coords = locations.reshape((locations.shape[0] // 2, 2))
+        gap_ends, gap_starts = gap_coords[:, ::-1].T
+        gap_lengths = gap_ends - gap_starts
+        cum_lens = gap_lengths.cumsum()
+        # convert to sequence coords
+        gap_pos = gap_starts.copy()
+        gap_pos[1:] -= cum_lens[:-1]
+        seq_length = aligned_length - cum_lens[-1]
+
+        return cls(
+            gap_pos=gap_pos,
+            cum_gap_lengths=cum_lens,
+            parent_length=seq_length,
+        )
+
+    # NOTE: cannot use string type hints with singledispatchmethod
+    @functools.singledispatchmethod
+    def __getitem__(self, item):
+        raise NotImplementedError(f"cannot slice using {type(item)}")
+
+    @__getitem__.register
+    def _(self, item: int):
+        return self[item : item + 1]
+
+    @__getitem__.register
+    def _(self, item: slice):
+        # we're assuming that this gap object is associated with a sequence
+        # that will also be sliced. Hence, we need to shift the gap insertion
+        # positions relative to this newly sliced sequence.
+        if item.step is not None:
+            raise NotImplementedError(
+                f"{type(self).__name__!r} does not yet support strides"
+            )
+        zero_array = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+        start = item.start or 0
+        stop = item.stop if item.stop is not None else len(self)
+
+        # convert negative indices
+        start = start if start >= 0 else len(self) + start
+        stop = stop if stop >= 0 else len(self) + stop
+        if min((start, stop)) < 0:
+            raise IndexError("item.start or item.stop is out of range")
+
+        if start >= stop:
+            # standard slice behaviour without negative step
+            return self.__class__(
+                gap_pos=zero_array.copy(),
+                cum_gap_lengths=zero_array.copy(),
+                parent_length=0,
+            )
+
+        # we address three easy cases:
+        # 1 - no gaps; 2 - slice before first gap; 3 - after last gap
+        no_gaps = self.__class__(
+            gap_pos=zero_array.copy(),
+            cum_gap_lengths=zero_array.copy(),
+            parent_length=stop - start,
+        )
+        if not self.num_gaps:
+            return no_gaps
+
+        first_gap = self.gap_pos[0]
+        last_gap = self.gap_pos[-1] + self.cum_gap_lengths[-1]
+        if stop < first_gap or start >= last_gap:
+            return no_gaps
+
+        gap_starts, gap_ends = _gap_spans(self.gap_pos, self.cum_gap_lengths)
+        gap_pos = self.gap_pos.copy()
+        cum_lengths = self.cum_gap_lengths.copy()
+        # we find where the slice starts
+        l = numpy.searchsorted(gap_ends, start, side="left")
+        if gap_starts[l] <= start < gap_ends[l] and stop <= gap_ends[l]:
+            # entire span is within a single gap
+            # pos now 0
+            gap_pos = numpy.array([0], dtype=_DEFAULT_GAP_DTYPE)
+            cum_lengths = cum_lengths[l : l + 1]
+            cum_lengths[0] = stop - start
+            return self.__class__(
+                gap_pos=gap_pos, cum_gap_lengths=cum_lengths, parent_length=0
+            )
+
+        lengths = self.get_gap_lengths()
+        if start < first_gap:
+            # start is before the first gap, we don't slice or shift
+            shift = start
+            begin = 0
+        elif gap_starts[l] <= start < gap_ends[l]:
+            # start is within a gap
+            # so the absolute gap_pos value remains unchanged, but we shorten
+            # the gap length
+            begin = l
+            begin_diff = start - gap_starts[l]
+            lengths[l] -= begin_diff
+            shift = (start - cum_lengths[l - 1] - begin_diff) if l else gap_pos[0]
+        elif start == gap_ends[l]:
+            # at gap boundary, so beginning of non-gapped segment
+            # no adjustment to gap lengths
+            begin = l + 1
+            shift = start - cum_lengths[l]
         else:
-            for i in self.spans:
-                if i.overlaps(other):
-                    return True
-        return False
-
-    def overlaps_extent(self, other):
-        """Returns True if any positions in self's extent also in other's."""
-        if hasattr(other, "extent"):
-            return self.extent.overlaps(other.extent)
+            # not within a gap
+            begin = l
+            shift = start - cum_lengths[l - 1] if l else start
+
+        # start search for stop from l index
+        r = numpy.searchsorted(gap_ends[l:], stop, side="right") + l
+        if r == self.num_gaps:
+            # stop is after last gap
+            end = r
+        elif gap_starts[r] < stop <= gap_ends[r]:
+            # within gap
+            end = r + 1
+            end_diff = gap_ends[r] - stop
+            lengths[r] -= end_diff
         else:
-            return self.extent.overlaps(other)
+            end = r
 
-    def sort(self):
-        """Sorts the spans in self."""
-        self.spans.sort()
+        pos_result = gap_pos[begin:end]
+        pos_result -= shift
+        lengths = lengths[begin:end]
+        parent_length = self.get_seq_index(stop) - self.get_seq_index(start)
 
-    def __iter__(self):
-        """Iterates over indices contained in self."""
-        return chain(*[iter(i) for i in self.spans])
+        return self.__class__(
+            gap_pos=pos_result,
+            gap_lengths=lengths,
+            parent_length=parent_length,
+        )
+
+    def get_align_index(self, seq_index: int, slice_stop: bool = False) -> int:
+        """convert a sequence index into an alignment index
+
+        Parameters
+        ----------
+        seq_index
+            coordinate on the sequence, must be < parent_length
+        slice_stop
+            set to True if the index is to be the end of an alignment slice.
+            In that case, and if seq_index is in gap_pos then it returns
+            the first alignment index of the gap run.
+        """
+        cum_lengths = self.cum_gap_lengths
+        gap_pos = self.gap_pos
+        # NOTE I explicitly cast all returned values to python int's due to
+        # need for json serialisation, which does not support numpy int classes
+        if seq_index < 0:
+            seq_index += self.parent_length
+
+        if seq_index < 0:
+            raise IndexError(f"{seq_index} negative seq_index beyond limit ")
+
+        if not self.num_gaps or seq_index < gap_pos[0]:
+            return int(seq_index)
+
+        # if stop_index, check if the seq_index corresponds to a gap position
+        if slice_stop and (match := seq_index == gap_pos).any():
+            # if so, we return the alignment coord for the first gap position
+            (idx,) = numpy.where(match)[0]
+            if idx:
+                gap_len = cum_lengths[idx] - cum_lengths[idx - 1]
+            else:
+                gap_len = cum_lengths[idx]
+            gap_end = gap_pos[idx] + cum_lengths[idx]
+            return int(gap_end - gap_len)
+
+        if seq_index >= gap_pos[-1]:
+            return int(seq_index + cum_lengths[-1])
+
+        # find gap position before seq_index
+        index = numpy.searchsorted(gap_pos, seq_index, side="left")
+        if seq_index < gap_pos[index]:
+            gap_lengths = cum_lengths[index - 1] if index else 0
+        else:
+            gap_lengths = cum_lengths[index]
+
+        return int(seq_index + gap_lengths)
+
+    def get_seq_index(self, align_index: int) -> int:
+        """converts alignment index to sequence index"""
+        # NOTE I explicitly cast all returned values to python int's due to
+        # need for json serialisation, which does not support numpy int classes
+        if align_index < 0:
+            align_index = len(self) + align_index
+        if align_index < 0:
+            raise IndexError(f"{align_index} align_index beyond limit")
+
+        if not self.num_gaps or align_index < self.gap_pos[0]:
+            return align_index
+
+        # these are alignment indices for gaps
+        cum_lengths = self.cum_gap_lengths
+        gap_starts, gap_ends = _gap_spans(self.gap_pos, cum_lengths)
+        if align_index >= gap_ends[-1]:
+            return int(align_index - cum_lengths[-1])
+
+        index = numpy.searchsorted(gap_ends, align_index, side="left")
+        if align_index < gap_starts[index]:
+            # before the gap at index
+            return int(align_index - cum_lengths[index - 1])
+
+        if align_index == gap_ends[index]:
+            # after the gap at index
+            return int(align_index - cum_lengths[index])
+
+        if gap_starts[index] <= align_index < gap_ends[index]:
+            # within the gap at index
+            # so the gap insertion position is the sequence position
+            return int(self.gap_pos[index])
+
+    def __len__(self) -> int:
+        length_gaps = self.cum_gap_lengths[-1] if self.num_gaps else 0
+        return int(self.parent_length + length_gaps)
+
+    def __add__(self, other: "IndelMap") -> "IndelMap":
+        """designed to support concatenation of two aligned sequences"""
+        gap_pos = self.gap_pos.tolist() + (self.parent_length + other.gap_pos).tolist()
+        gap_pos = numpy.array(gap_pos, dtype=_DEFAULT_GAP_DTYPE)
+
+        cum_length = self.cum_gap_lengths[-1] if self.num_gaps else 0
+        cum_gap_lengths = (
+            self.cum_gap_lengths.tolist()
+            + (cum_length + other.cum_gap_lengths).tolist()
+        )
+        cum_gap_lengths = numpy.array(cum_gap_lengths, dtype=_DEFAULT_GAP_DTYPE)
 
-    def _get_extent(self):
-        """Returns Span object representing the extent of self."""
-        return Span(self.start, self.end)
+        return self.__class__(
+            gap_pos=gap_pos,
+            cum_gap_lengths=cum_gap_lengths,
+            parent_length=self.parent_length + other.parent_length,
+        )
 
-    extent = property(_get_extent)
+    def __mul__(self, scale: int) -> "IndelMap":
+        """used for going from amino-acid alignment to codon alignment"""
+        gap_pos = self.gap_pos * scale
+        cum_gap_lengths = self.cum_gap_lengths * scale
+        return self.__class__(
+            gap_pos=gap_pos,
+            cum_gap_lengths=cum_gap_lengths,
+            parent_length=self.parent_length * scale,
+        )
 
-    def simplify(self):
-        """Reduces the spans in self in-place to get fewest spans.
+    def __repr__(self) -> str:
+        gap_data = numpy.array([self.gap_pos, self.cum_gap_lengths]).T
+        return f"{gap_data.tolist()!r}/{self.parent_length}"
+
+    def get_gap_lengths(self) -> IntArrayTypes:
+        lengths = self.cum_gap_lengths.copy()
+        lengths[1:] = numpy.diff(lengths)
+        return lengths
+
+    def nongap(self) -> Iterator[SpanTypes]:
+        """ungappeed segments in this map in aligned coordinates"""
+        # we want to know the coordinates of the ungapped segments on
+        # the aligned sequence. The gap_pos attribute is in sequence
+        # coordinates
+        prev_pos = 0
+        for i, pos in enumerate(self.gap_pos):
+            if pos == 0:
+                # we start with a gap
+                prev_pos = pos
+                continue
 
-        Will not condense spans with opposite directions.
+            cum_length = 0 if i == 0 else self.cum_gap_lengths[i - 1]
+            start = 0 if i == 0 else prev_pos
+            start += cum_length
+            end = self.gap_pos[i] + cum_length
+            yield Span(start, end)
+            prev_pos = pos
+
+        if self.num_gaps and self.gap_pos[-1] + self.cum_gap_lengths[-1] < len(self):
+            yield Span(self.gap_pos[-1] + self.cum_gap_lengths[-1], len(self))
+
+    @property
+    def spans(self) -> Iterator[SpanTypes]:
+        """generator of spans"""
+        if not self.num_gaps:
+            yield Span(0, self.parent_length)
+            return
+
+        for i, pos in enumerate(self.gap_pos):
+            cum_length = self.cum_gap_lengths[i]
+            if pos == 0:
+                cls = TerminalPadding if self.termini_unknown else LostSpan
+                yield cls(cum_length)
+                continue
 
-        Will condense adjacent but nonoverlapping spans (e.g. (1,3) and (4,5)).
+            if i == 0:
+                start = 0
+                prev_length = 0
+            else:
+                start = self.gap_pos[i - 1]
+                prev_length = self.cum_gap_lengths[i - 1]
+            yield Span(start, pos)
+            cls = (
+                TerminalPadding
+                if self.termini_unknown and i == self.num_gaps - 1
+                else LostSpan
+            )
+            yield cls(cum_length - prev_length)
+
+        if self.num_gaps and self.gap_pos[-1] < self.parent_length:
+            yield Span(self.gap_pos[-1], self.parent_length)
+
+    @property
+    def complete(self) -> bool:
+        """whether any span represents a gap"""
+        return self.num_gaps != 0 and self.useful
+
+    @property
+    def useful(self) -> bool:
+        return self.parent_length != 0
+
+    def get_coordinates(self) -> SeqCoordTypes:
+        """returns sequence coordinates of ungapped segments
+
+        Returns
+        -------
+        [(start, end), ...]
         """
-        forward = []
-        reverse = []
-        spans = self.spans[:]
-        spans.sort()
+        if not self.num_gaps or (self.num_gaps == 1 and not self.gap_pos[0]):
+            return [(0, int(self.parent_length))]
+        elif self.num_gaps == 1:
+            # does not start with a gap
+            starts = [0, int(self.gap_pos[0])]
+            ends = [int(self.gap_pos[0]), self.parent_length]
+            return list(zip(starts, ends))
+
+        starts = self.gap_pos[:-1].tolist()
+        ends = self.gap_pos[1:].tolist()
+        if self.gap_pos[0]:
+            # does not start with a gap
+            ends = starts[:1] + ends
+            starts = [0] + starts
+
+        if self.gap_pos[-1] + self.cum_gap_lengths[-1] < self.parent_length:
+            # does end with a gap
+            starts.append(ends[-1])
+            ends.append(self.parent_length)
+
+        return list(zip(starts, ends))
+
+    def get_gap_coordinates(self) -> SeqCoordTypes:
+        """returns [(gap pos, gap length), ...]"""
+        lengths = self.get_gap_lengths()
+        return numpy.array([self.gap_pos, lengths]).T.tolist()
+
+    def get_gap_align_coordinates(self) -> SeqCoordTypes:
+        """returns [(gap start, gap end), ...] in alignment indices
+
+        Returns
+        -------
+        A 2D numpy array of integers. If the result is empty, it still
+        has shape (0, 2).
+        """
+        starts, ends = _gap_spans(self.gap_pos, self.cum_gap_lengths)
+        result = numpy.array([starts, ends]).T
+        if not len(result):
+            result = result.reshape((0, 2))
+        return result
+
+    def merge_maps(self, other, parent_length: Optional[int] = None) -> "IndelMap":
+        """merge gaps of other with self
+
+        Parameters
+        ----------
+        indel_map
+            instance for same sequence
+        parent_length
+            overrides property
+        """
+        unique_pos = numpy.union1d(self.gap_pos, other.gap_pos)
+        gap_lengths = numpy.zeros(unique_pos.shape, dtype=_DEFAULT_GAP_DTYPE)
+        self_lengths = self.get_gap_lengths()
+        other_lengths = other.get_gap_lengths()
+        _update_lengths(unique_pos, gap_lengths, self.gap_pos, self_lengths)
+        _update_lengths(unique_pos, gap_lengths, other.gap_pos, other_lengths)
+        parent_length = parent_length or self.parent_length
+        return self.__class__(
+            gap_pos=unique_pos,
+            gap_lengths=gap_lengths,
+            parent_length=parent_length,
+        )
+
+    def joined_segments(self, coords: SeqCoordTypes) -> "IndelMap":
+        """returns new map with disjoint gapped segments joined
+
+        Parameters
+        ----------
+        coords
+            sequence insert gap coordinates [(gap start, gap end), ...]
+        """
+        coords = sorted(coords)
+        # using a dict here because joining can produce a gap merge
+        gaps = {}
+        cum_length = 0
+        cum_parent_length = 0
+        for start, end in coords:
+            im = self[start:end]
+            for i in range(im.num_gaps):
+                pos = im.gap_pos[i] + cum_parent_length
+                gaps[pos] = gaps.get(pos, cum_length) + im.cum_gap_lengths[i]
+            cum_parent_length += im.parent_length
+            if im.num_gaps:
+                cum_length += im.cum_gap_lengths[-1]
+        gap_pos = numpy.empty(len(gaps), dtype=_DEFAULT_GAP_DTYPE)
+        cum_lengths = numpy.empty(len(gaps), dtype=_DEFAULT_GAP_DTYPE)
+        for i, (pos, length) in enumerate(sorted(gaps.items())):
+            gap_pos[i] = pos
+            cum_lengths[i] = length
+        return self.__class__(
+            gap_pos=gap_pos,
+            cum_gap_lengths=cum_lengths,
+            parent_length=cum_parent_length,
+        )
+
+    def nucleic_reversed(self) -> "IndelMap":
+        """map for a sequence that has itself been reversed and complemented
+
+        Notes
+        -----
+        discards reverse attribute on both spans and self
+        """
+        new_pos = self.gap_pos.copy()
+        lengths = self.get_gap_lengths()
+
+        if len(new_pos):
+            new_pos = self.parent_length - new_pos
+            new_pos = new_pos[::-1]
+            lengths = lengths[::-1]
+
+        return self.__class__(
+            gap_pos=new_pos,
+            gap_lengths=lengths,
+            parent_length=self.parent_length,
+        )
+
+    def to_rich_dict(self) -> dict[str, Any]:
+        """returns dicts for contained spans [dict(), ..]"""
+        # exclude spans from deep copy since being overwritten
+        data = copy.deepcopy(dict(self._serialisable.items()))
+        data["type"] = get_object_provenance(self)
+        data["version"] = __version__
+        data["gap_pos"] = self.gap_pos.tolist()
+        data["cum_gap_lengths"] = self.cum_gap_lengths.tolist()
+        data["parent_length"] = int(self.parent_length)
+        return data
+
+    def to_json(self) -> str:
+        return json.dumps(self.to_rich_dict())
+
+    @classmethod
+    def from_rich_dict(cls, map_element) -> "IndelMap":
+        from cogent3.util.deserialise import _get_class
+
+        map_element.pop("version", None)
+        type_ = map_element.pop("type")
+        assert _get_class(type_) == cls
+        map_element["gap_pos"] = numpy.array(map_element["gap_pos"])
+        map_element["cum_gap_lengths"] = numpy.array(map_element["cum_gap_lengths"])
+
+        return cls(**map_element)
+
+    def with_termini_unknown(self) -> "IndelMap":
+        """returns new instance with terminal gaps indicated as unknown"""
+        return self.__class__(
+            gap_pos=self.gap_pos.copy(),
+            cum_gap_lengths=self.cum_gap_lengths.copy(),
+            parent_length=self.parent_length,
+            termini_unknown=True,
+        )
+
+    def to_feature_map(self) -> "FeatureMap":
+        """returns a Map type, suited to Features"""
+        return FeatureMap(spans=list(self.spans), parent_length=self.parent_length)
+
+    def make_seq_feature_map(self, align_feature_map: "FeatureMap") -> "FeatureMap":
+        """converts align_feature_map to a FeatureMap with sequence coordinates
+
+        Parameters
+        ----------
+        align_feature_map
+            with alignment coordinates
+
+        Notes
+        -----
+        LostSpans in align_feature_map are skipped
+        """
+        spans = []
+        for span in align_feature_map.spans:
+            if span.lost:
+                continue
+
+            start = self.get_seq_index(span.start)
+            end = self.get_seq_index(span.end)
+            spans.append(Span(start, end))
+
+        return FeatureMap(spans=spans, parent_length=self.parent_length)
+
+
+@dataclasses.dataclass
+class FeatureMap(MapABC):
+    """A map holds a list of spans."""
+
+    spans: dataclasses.InitVar[Optional[SeqSpanTypes]] = ()
+    parent_length: int = 0
+    offsets: list[int] = dataclasses.field(init=False, repr=False)
+    useful: bool = dataclasses.field(init=False, repr=False, default=False)
+    complete: bool = dataclasses.field(init=False, repr=False, default=True)
+    _serialisable: dict = dataclasses.field(init=False, repr=False)
+    _spans: SeqSpanTypes = dataclasses.field(default=(), init=False)
+    _start: Optional[int] = dataclasses.field(default=None, init=False)
+    _end: Optional[int] = dataclasses.field(default=None, init=False)
+
+    def __post_init__(self, spans: SeqSpanTypes):
+        assert self.parent_length is not None
+        self.parent_length = int(self.parent_length)
+        if isinstance(spans, property):
+            # This clause is due a known issue with dataclasses.
+            # As we have a spans property, the default spans value is
+            # ignored, so we have to check for its value being a property
+            # and then set the default value here
+            spans = ()
+
+        spans = tuple(spans)
+
+        self.offsets = []
+        self.useful = False
+        self.complete = True
+        posn = 0
         for span in spans:
-            if span.reverse:
-                direction = reverse
+            self.offsets.append(posn)
+            posn += span.length
+            if span.lost:
+                self.complete = False
+            elif not self.useful:
+                self.useful = True
+                self._start, self._end = span.start, span.end
             else:
-                direction = forward
-
-            found_overlap = False
-            for other in direction:
-                if (
-                    span.overlaps(other)
-                    or (span.start == other.end)
-                    or (other.start == span.end)
-                ):  # handle adjacent spans also
-                    other.start = min(span.start, other.start)
-                    other.end = max(span.end, other.end)
-                    found_overlap = True
-                    break
-            if not found_overlap:
-                direction.append(span)
-        self.spans[:] = forward + reverse
-
-
-@c3warn.deprecated_callable(
-    version="2024.3", reason="No being used.", is_discontinued=True
-)
-class Point(Span):  # pragma: no cover
-    """Point is a special case of Span, where start always equals end.
+                self._start = min(self._start, span.start)
+                self._end = max(self._end, span.end)
 
-    Note that, as per Python standard, a point is _between_ two elements
-    in a sequence. In other words, a point does not contain any elements.
-    If you want a single element, use a Span where end = start + 1.
+        self._spans = tuple(spans)
+        self.length = posn
 
-    A Point does have a direction (i.e. a reverse property) to indicate
-    where successive items would go if it were expanded.
-    """
+    @classmethod
+    def from_spans(
+        cls, spans: SeqSpanTypes, parent_length: int, **kwargs
+    ) -> "FeatureMap":
+        return cls(spans=spans, parent_length=parent_length)
+
+    def __len__(self) -> int:
+        return self.length
+
+    def __repr__(self) -> str:
+        return f"{list(self.spans)!r}/{self.parent_length}"
+
+    def __getitem__(self, new_map) -> "FeatureMap":
+        # A possible shorter map at the same level
+        new_map = as_map(new_map, len(self), self.__class__)
+        new_parts = []
+        for span in new_map.spans:
+            new_parts.extend(span.remap_with(self))
+        return self.__class__(spans=new_parts, parent_length=self.parent_length)
 
-    def __init__(self, start, reverse=False):
-        """Returns new Point object."""
-        self.reverse = reverse
-        self._start = start
+    def __mul__(self, scale) -> "FeatureMap":
+        new_parts = [span * scale for span in self.spans]
+        return self.__class__(spans=new_parts, parent_length=self.parent_length * scale)
 
-    def _get_start(self):
-        """Returns self.start."""
-        return self._start
+    def __truediv__(self, scale: int) -> "FeatureMap":
+        new_parts = [span / scale for span in self.spans]
+        return self.__class__(
+            spans=new_parts, parent_length=self.parent_length // scale
+        )
 
-    def _set_start(self, start):
-        """Sets self.start and self.end."""
-        self._start = start
+    def __add__(self, other) -> "FeatureMap":
+        if other.parent_length != self.parent_length:
+            raise ValueError("Those maps belong to different sequences")
+        return self.__class__(
+            spans=list(self.spans) + list(other.spans), parent_length=self.parent_length
+        )
 
-    start = property(_get_start, _set_start)
-    end = start  # start and end are synonyms for the same property
+    @property
+    def spans(self) -> Iterator[SeqSpanTypes]:
+        yield from self._spans
 
+    def get_covering_span(self) -> "FeatureMap":
+        span = (self.start, self.end)
+        return self.__class__.from_locations(
+            locations=[span], parent_length=self.parent_length
+        )
 
-@c3warn.deprecated_callable(
-    version="2024.3", reason="No being used.", is_discontinued=True
-)
-def RangeFromString(string, delimiter=","):  # pragma: no cover
-    """Returns Range object from string of the form 1-5,11,20,30-50.
+    def covered(self) -> "FeatureMap":
+        """>>> Map([(10,20), (15, 25), (80, 90)]).covered().spans
+        [Span(10,25), Span(80, 90)]"""
 
-    Ignores whitespace; expects values to be comma-delimited and positive.
-    """
-    result = Range()
-    pairs = list(map(strip, string.split(delimiter)))
-    for p in pairs:
-        if not p:  # adjacent delimiters?
-            continue
-        if "-" in p:  # treat as pair
-            first, second = p.split("-")
-            result.spans.append(Span(int(first), int(second)))
-        else:
-            result.spans.append(Span(int(p)))
-    return result
+        delta = {}
+        for span in self.spans:
+            if span.lost:
+                continue
+            delta[span.start] = delta.get(span.start, 0) + 1
+            delta[span.end] = delta.get(span.end, 0) - 1
+        positions = sorted(delta.keys())
+        last_y = y = 0
+        last_x = start = None
+        result = []
+        for x in positions:
+            y += delta[x]
+            if x == last_x:
+                continue
+            if y and not last_y:
+                assert start is None
+                start = x
+            elif last_y and not y:
+                result.append((start, x))
+                start = None
+            last_x = x
+            last_y = y
+        assert y == 0
+        return self.__class__.from_locations(
+            locations=result, parent_length=self.parent_length
+        )
+
+    def nucleic_reversed(self) -> "FeatureMap":
+        """map for a sequence that has itself been reversed and complemented
+
+        Notes
+        -----
+        discards reverse attribute on both spans and self
+        """
+        spans = []
+        parent_length = self.parent_length
+        for s in self.spans:
+            if not s.lost:
+                start = parent_length - s.end
+                assert start >= 0
+                end = start + s.length
+                s = Span(start=start, end=end)
+            spans.append(s)
+
+        spans.reverse()
+        return self.__class__(spans=spans, parent_length=self.parent_length)
+
+    def get_gap_coordinates(self) -> SeqCoordTypes:
+        """returns [(gap pos, gap length), ...]"""
+        gap_pos = []
+        spans = list(self.spans)
+        for i, span in enumerate(spans):
+            if not span.lost:
+                continue
+
+            pos = spans[i - 1].end if i else 0
+            gap_pos.append((pos, len(span)))
+
+        return gap_pos
+
+    def gaps(self) -> "FeatureMap":
+        """The gaps (lost spans) in this map"""
+        locations = []
+        offset = 0
+        for s in self.spans:
+            if s.lost:
+                locations.append((offset, offset + s.length))
+            offset += s.length
+        return self.__class__.from_locations(
+            locations=locations, parent_length=len(self)
+        )
+
+    def shadow(self) -> "FeatureMap":
+        """The 'negative' map of the spans not included in this map"""
+        return self.inverse().gaps()
+
+    def nongap(self) -> SeqSpanTypes:
+        locations = []
+        offset = 0
+        for s in self.spans:
+            if not s.lost:
+                locations.append((offset, offset + s.length))
+            offset += s.length
+        return _spans_from_locations(locations=locations, parent_length=len(self))
+
+    def without_gaps(self) -> "FeatureMap":
+        return self.__class__(
+            spans=[s for s in self.spans if not s.lost],
+            parent_length=self.parent_length,
+        )
+
+    def inverse(self) -> "FeatureMap":
+        """returns instance with coordinates updated for aligned, unaligned"""
+        # is this only required for parse_out_gaps?
+        # NO also used in cogent3.align code
+
+        # can't work if there are overlaps in the map
+        # tidy ends don't survive inversion
+        if self.parent_length is None:
+            raise ValueError("Uninvertable. parent length not known")
+
+        cum_posn = 0
+        temp = []
+        for span in self.spans:
+            if not span.lost:
+                if span.reverse:
+                    temp.append(
+                        (span.start, span.end, cum_posn + span.length, cum_posn)
+                    )
+                else:
+                    temp.append(
+                        (span.start, span.end, cum_posn, cum_posn + span.length)
+                    )
+            cum_posn += span.length
+
+        temp.sort()
+        new_spans = []
+        last_start = 0
+        for start, end, cum_start, cum_end in temp:
+            if start > last_start:
+                new_spans.append(LostSpan(start - last_start))
+            elif start < last_start:
+                raise ValueError(f"Uninvertable. Overlap: {start} < {last_start}")
+
+            # we force tidy_<start/end> to be same as self, attribute has no meaning
+            # for IndelMap, but retained for compatability for now
+            new_spans.append(
+                Span(
+                    cum_start,
+                    cum_end,
+                    reverse=cum_start > cum_end,
+                )
+            )
+            last_start = end
+
+        if self.parent_length > last_start:
+            new_spans.append(LostSpan(self.parent_length - last_start))
+
+        return self.__class__(spans=new_spans, parent_length=len(self))
+
+    def get_coordinates(self) -> SeqCoordTypes:
+        """returns span coordinates as [(v1, v2), ...]
+
+        v1/v2 are (start, end) unless the map is reversed, in which case it will
+        be (end, start)"""
+
+        return [(s.start, s.end) for s in self.spans if not s.lost]
+
+    def to_rich_dict(self) -> dict[str, Any]:
+        """returns dicts for contained spans [dict(), ..]"""
+        spans = [s.to_rich_dict() for s in self.spans]
+        data = copy.deepcopy(self._serialisable)
+        data.pop("locations", None)
+        data["spans"] = spans
+        data["type"] = get_object_provenance(self)
+        data["version"] = __version__
+        data["parent_length"] = int(self.parent_length)
+        return data
 
+    def to_json(self) -> str:
+        return json.dumps(self.to_rich_dict())
 
-def gap_coords_to_map(gaps_lengths: dict, seq_length: int) -> Map:
+    @classmethod
+    def from_rich_dict(cls, map_element) -> "FeatureMap":
+        from cogent3.util.deserialise import _get_class
+
+        map_element.pop("version", None)
+        type_ = map_element.pop("type")
+        assert _get_class(type_) == cls
+        spans = []
+        for element in map_element.pop("spans"):
+            element.pop("version", None)
+            klass = _get_class(element.pop("type"))
+            instance = klass(**element)
+            spans.append(instance)
+
+        map_element["spans"] = spans
+        return cls(**map_element)
+
+    def zeroed(self) -> "FeatureMap":
+        """returns a new instance with the first span starting at 0
+
+        Note
+        ----
+
+        Useful when an Annotatable object is sliced, but the connection to
+        the original parent is being deliberately broken as in the
+        Sequence.deepcopy(sliced=True) case.
+        """
+        # todo there's probably a more efficient way to do this
+        # create the new instance
+        from cogent3.util.deserialise import deserialise_map_spans
+
+        data = self.to_rich_dict()
+        zeroed = deserialise_map_spans(data)
+        zeroed.parent_length = len(zeroed.get_covering_span())
+        shift = min(zeroed.start, zeroed.end)
+        new_end = 0
+        for span in zeroed.spans:
+            if span.lost:
+                continue
+            span.start -= shift
+            span.end -= shift
+            new_end = max(new_end, span.end)
+
+        zeroed._start = 0
+        zeroed._end = new_end
+
+        return zeroed
+
+    def absolute_position(self, rel_pos: IntTypes) -> IntTypes:
+        """converts rel_pos into an absolute position
+
+        Raises
+        ------
+        raises ValueError if rel_pos < 0
+        """
+        check = (
+            numpy.array([rel_pos], dtype=_DEFAULT_GAP_DTYPE)
+            if isinstance(rel_pos, int)
+            else rel_pos
+        )
+        if check.min() < 0:
+            raise ValueError(f"must positive, not {rel_pos=}")
+
+        return rel_pos if len(self) == self.parent_length else self.start + rel_pos
+
+    def relative_position(self, abs_pos: IntTypes) -> IntTypes:
+        """converts abs_pos into an relative position
+
+        Raises
+        ------
+        raises ValueError if abs_pos < 0
+        """
+        check = (
+            numpy.array([abs_pos], dtype=_DEFAULT_GAP_DTYPE)
+            if isinstance(abs_pos, int)
+            else abs_pos
+        )
+        if check.min() < 0:
+            raise ValueError(f"must positive, not {abs_pos=}")
+        return abs_pos - self.start
+
+    @property
+    def start(self):
+        return self._start or 0
+
+    @property
+    def end(self):
+        return self._end or 0
+
+
+def gap_coords_to_map(
+    gaps_lengths: dict[IntTypes, IntTypes], seq_length: int
+) -> IndelMap:
     """
     Parameters
     ----------
@@ -1109,21 +1950,53 @@ def gap_coords_to_map(gaps_lengths: dict, seq_length: int) -> Map:
     """
 
     if not gaps_lengths:
-        return Map([(0, seq_length)], parent_length=seq_length)
+        gap_pos = numpy.array([], dtype=_DEFAULT_GAP_DTYPE)
+        lengths = gap_pos.copy()
+    else:
+        gap_pos, lengths = list(zip(*sorted(gaps_lengths.items())))
+        gap_pos = numpy.array(gap_pos, dtype=_DEFAULT_GAP_DTYPE)
+        lengths = numpy.array(lengths, dtype=_DEFAULT_GAP_DTYPE)
+
+    return IndelMap(gap_pos=gap_pos, gap_lengths=lengths, parent_length=seq_length)
+
+
+@register_deserialiser(get_object_provenance(IndelMap))
+def deserialise_indelmap(data: dict) -> IndelMap:
+    return IndelMap.from_rich_dict(data)
+
+
+@register_deserialiser(get_object_provenance(FeatureMap))
+def deserialise_featuremap(data: dict) -> FeatureMap:
+    return FeatureMap.from_rich_dict(data)
+
+
+@functools.singledispatch
+def _norm_slice(index, length: int) -> tuple[int, int, Union[int, None]]:
+    """_norm_slice(slice(1, -2, 3), 10) -> (1,8,3)"""
+    start = index
+    if start < 0:
+        start += length
+    if start >= length:
+        raise IndexError(index)
+    return start, start + 1, 1
+
+
+@_norm_slice.register
+def _(index: slice, length: int) -> tuple[int, int, Union[int, None]]:
+    start = _norm_index(index.start, length, 0)
+    end = _norm_index(index.stop, length, length)
+    return start, end, index.step
 
-    spans = []
-    last = pos = 0
-    for pos in sorted(gaps_lengths):
-        if pos > seq_length:
-            raise ValueError(
-                f"cannot have gap at position {pos} beyond seq_length= {seq_length}"
-            )
 
-        gap = LostSpan(length=gaps_lengths[pos])
-        spans.extend([gap] if pos == 0 else [Span(last, pos), gap])
-        last = pos
+@_norm_slice.register
+def _(index: Span, length) -> tuple[int, int, Union[int, None]]:
+    start = _norm_index(index.start, length, 0)
+    end = _norm_index(index.end, length, length)
+    return start, end, None
 
-    if pos < seq_length:
-        spans.append(Span(last, seq_length))
 
-    return Map(spans=spans, parent_length=seq_length)
+@_norm_slice.register
+def _(index: FeatureMap, length) -> tuple[int, int, Union[int, None]]:
+    start = _norm_index(index.start, length, 0)
+    end = _norm_index(index.end, length, length)
+    return start, end, None
diff --git a/src/cogent3/core/moltype.py b/src/cogent3/core/moltype.py
index 9ebc02a08..9cfcf5f45 100644
--- a/src/cogent3/core/moltype.py
+++ b/src/cogent3/core/moltype.py
@@ -734,7 +734,28 @@ def get_type(self):  # pragma: no cover
         return self.label
 
     def make_seq(self, seq, name=None, **kwargs):
-        """Returns sequence of correct type."""
+        """Returns sequence generated by seq_constructor argument to self..
+
+        Parameters
+        ----------
+        seq
+            valid input to the coerce_str function
+        name
+            value assigned to sequence.name attribute
+        kwargs
+            optional keyword arguments to bound sequence constructor class
+
+        Notes
+        -----
+        If input seq has a moltype attribute that is self, seq is
+        returned unmodified. If the moltype is not self, then the
+        sequence is converted using seq.to_moltype(self) .
+        """
+        if getattr(seq, "moltype", None) is self and (name is None or seq.name == name):
+            return seq
+
+        if hasattr(seq, "to_moltype") and (name is None or seq.name == name):
+            return seq.to_moltype(self)
 
         name = name or getattr(seq, "name", None)
         if isinstance(seq, ArraySequence):
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 092f2ccb4..2b28e59e7 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -10,6 +10,7 @@
 performance reasons, but don't alter the MolType or the sequence data after
 creation.
 """
+
 from __future__ import annotations
 
 import contextlib
@@ -42,6 +43,8 @@
 )
 from numpy.random import permutation
 
+import cogent3.util.warning as c3warn
+
 from cogent3._version import __version__
 from cogent3.core.alphabet import AlphabetError
 from cogent3.core.annotation import Feature
@@ -54,7 +57,7 @@
 )
 from cogent3.core.genetic_code import get_code
 from cogent3.core.info import Info as InfoClass
-from cogent3.core.location import LostSpan, Map
+from cogent3.core.location import FeatureMap, IndelMap, LostSpan
 from cogent3.format.fasta import alignment_to_fasta
 from cogent3.maths.stats.contingency import CategoryCounts
 from cogent3.maths.stats.number import CategoryCounter
@@ -66,7 +69,6 @@
     get_setting_from_environ,
 )
 from cogent3.util.transform import for_seq, per_shortest
-from cogent3.util.warning import deprecated_args
 
 
 ARRAY_TYPE = type(array(1))
@@ -141,7 +143,7 @@ def to_rich_dict(self, exclude_annotations=False):
             version=__version__,
         )
         if hasattr(self, "annotation_offset"):
-            offset = self._seq.offset + self._seq.start
+            offset = int(self._seq.parent_start)
             data.update(dict(annotation_offset=offset))
 
         if (
@@ -167,6 +169,7 @@ def counts(
         include_ambiguity=False,
         allow_gap=False,
         exclude_unobserved=False,
+        warn=False,
     ):
         """returns dict of counts of motifs
 
@@ -183,7 +186,9 @@ def counts(
             if True, motifs containing a gap character are included.
         exclude_unobserved
             if True, unobserved motif combinations are excluded.
-
+        warn
+            warns if motif_length > 1 and alignment trimmed to produce
+            motif columns
         """
         try:
             data = str(self)
@@ -195,7 +200,7 @@ def counts(
         if motif_length == 1:
             counts = CategoryCounter(data)
         else:
-            if len(data) % motif_length != 0:
+            if warn and len(data) % motif_length != 0:
                 warnings.warn(
                     f"{self.name} length not divisible by {motif_length}, truncating"
                 )
@@ -818,7 +823,7 @@ def __init__(
             else (lambda x: x)
         )
 
-        self._seq = _coerce_seq(seq, preserve_case, checker)
+        self._seq = _coerce_to_seqview(seq, self.name, preserve_case, checker)
 
         if not isinstance(info, InfoClass):
             try:
@@ -851,7 +856,7 @@ def annotation_offset(self):
             int: The offset between annotation coordinates and sequence coordinates.
         """
 
-        return self._seq.offset + self._seq.start
+        return self._seq.parent_start
 
     @annotation_offset.setter
     def annotation_offset(self, value):
@@ -916,7 +921,7 @@ def get_features(
         name
             name of the feature
         start, stop
-            start, end positions to search between, relative to offset
+            start, stop positions to search between, relative to offset
             of this sequence. If not provided, entire span of sequence is used.
 
         Notes
@@ -936,20 +941,26 @@ def get_features(
         start = start + len(self) if start < 0 else start
         stop = stop + len(self) if stop < 0 else stop
 
-        # sort start / stop
         start, stop = (start, stop) if start < stop else (stop, start)
 
         # note: offset is handled by absolute_position
         # we set include_boundary=False because start is inclusive indexing,
         # i,e., the start cannot be equal to the length of the view
+        (
+            # parent_id can differ from self.name if there was a
+            # rename operation
+            parent_id,
+            *_,
+            strand,
+        ) = self.parent_coordinates()
         query_start = self._seq.absolute_position(start, include_boundary=False)
         # we set include_boundary=True because stop is exclusive indexing,
         # i,e., the stop can be equal to the length of the view
-        query_end = self._seq.absolute_position(stop, include_boundary=True)
+        query_stop = self._seq.absolute_position(stop, include_boundary=True)
 
-        reversed = query_end < query_start
-        if reversed:
-            query_start, query_end = query_end, query_start
+        rev_strand = strand == -1
+        if rev_strand:
+            query_start, query_stop = query_stop, query_start
 
         query_start = max(query_start, 0)
         # in the underlying db, features are always plus strand oriented
@@ -969,22 +980,23 @@ def get_features(
         # flag which comes back from the db
 
         for feature in self.annotation_db.get_features_matching(
-            seqid=self.name,
+            seqid=parent_id,
             name=name,
             biotype=biotype,
             start=query_start,
-            end=query_end,  # todo gah end should be stop
+            stop=query_stop,
             allow_partial=allow_partial,
         ):
             # spans need to be converted from absolute to relative positions
             # DO NOT do adjustment in make_feature since that's user facing,
-            # and we expect them to make a feature manually
+            # and we expect them to make a feature manually wrt to their
+            # current view
             spans = array(feature.pop("spans"), dtype=int)
             for i, v in enumerate(spans.ravel()):
                 rel_pos = self._seq.relative_position(v)
                 spans.ravel()[i] = rel_pos
 
-            if reversed:
+            if rev_strand:
                 # see above comment
                 spans = len(self) - spans
 
@@ -998,11 +1010,6 @@ def _relative_spans(self, spans):
             # reverse feature determined by absolute position
             reverse = s > e
 
-            # todo: kath, think about logic of stop=true for reverse locations
-            # I think, we need to know the whether it is reverse so that the adjustment
-            # for step (given stop=True) is in the correct direction.
-            # However, we need to keep s > e for a reverse span because this information
-            # is used in the Map?
             if reverse:
                 start = self._seq.relative_position(s, stop=True)
                 end = self._seq.relative_position(e)
@@ -1031,11 +1038,9 @@ def make_feature(self, feature: FeatureDataType, *args) -> Feature:
         """
         feature = dict(feature)
         seq_rced = self._seq.reverse
-        # todo gah check consistency of relationship between reversed and strand
-        # i.e. which object has responsibility for transforming the strand value
-        # (a string) into a bool?
-        revd = feature.pop("reversed", None) or feature.pop("strand", None) == "-"
         spans = feature.pop("spans", None)
+        revd = feature.pop("strand", None) == "-"
+        feature["strand"] = "+" if revd == seq_rced else "-"
 
         vals = array(spans)
         pre = abs(vals.min()) if vals.min() < 0 else 0
@@ -1055,32 +1060,19 @@ def make_feature(self, feature: FeatureDataType, *args) -> Feature:
                 continue
             new_spans.append(new.tolist())
 
-        fmap = Map(locations=new_spans, parent_length=len(self))
+        fmap = FeatureMap.from_locations(locations=new_spans, parent_length=len(self))
         if pre or post:
             # create a lost span to represent the segment missing from
             # the instance
-            spans = fmap.spans
+            spans = list(fmap.spans)
             if pre:
                 spans.insert(0, LostSpan(pre))
             if post:
                 spans.append(LostSpan(post))
-            fmap = Map(spans=spans, parent_length=len(self))
-
-        if revd and not seq_rced:
-            # the sequence is on the plus strand, and the
-            # feature coordinates are also for the plus strand
-            # but their order needs to be changed to indicate
-            # reverse complement is required
-            fmap = fmap.reversed()
-        elif seq_rced and not revd:
-            # plus strand feature, but the sequence reverse complemented
-            # so we need to nucleic-reverse the map
+            fmap = FeatureMap(spans=spans, parent_length=len(self))
+
+        if seq_rced:
             fmap = fmap.nucleic_reversed()
-        elif seq_rced:
-            # sequence is rc'ed, the feature was minus strand of
-            # original, so needs to be both nucleic reversed and
-            # then reversed
-            fmap = fmap.nucleic_reversed().reversed()
 
         feature.pop("on_alignment", None)
         feature.pop("seqid", None)
@@ -1205,11 +1197,24 @@ def copy_annotations(self, seq_db: SupportsFeatures) -> None:
 
         self.annotation_db.update(seq_db, seqids=self.name)
 
-    def copy(self, exclude_annotations=False):
-        """returns a copy of self"""
-        offset = self._seq.offset + self._seq.start
+    def copy(self, exclude_annotations: bool = False, sliced: bool = True):
+        """returns a copy of self
+
+        Parameters
+        -----------
+        sliced
+            Slices underlying sequence with start/end of self coordinates. The offset
+            is retained.
+        exclude_annotations
+            drops annotation_db when True
+        """
+        annotation_offset = self.annotation_offset if sliced else self._seq.offset
+        data = self._seq.copy(sliced=sliced)
         new = self.__class__(
-            self._seq[:], name=self.name, info=self.info, annotation_offset=offset
+            data,
+            name=self.name,
+            info=self.info,
+            annotation_offset=annotation_offset,
         )
         db = None if exclude_annotations else copy.deepcopy(self.annotation_db)
         new._annotation_db = db
@@ -1251,7 +1256,8 @@ def with_masked_annotations(
                 seqid=self.name,
                 name=None,
                 biotype=None,
-                map=Map(locations=[], parent_length=len(self)),
+                map=FeatureMap.from_locations(locations=[], parent_length=len(self)),
+                strand="+",
             )
         else:
             region = annotations[0].union(annotations[1:])
@@ -1261,8 +1267,8 @@ def with_masked_annotations(
 
         i = 0
         segments = []
-        fmap = region.map.reversed() if region.map.reverse else region.map
-        for b, e in fmap.get_coordinates():
+        coords = region.map.get_coordinates()
+        for b, e in coords:
             segments.extend((str(self[i:b]), mask_char * (e - b)))
             i = e
         segments.append(str(self[i:]))
@@ -1273,20 +1279,19 @@ def with_masked_annotations(
         new.annotation_db = self.annotation_db
         return new
 
-    def gapped_by_map_segment_iter(self, map, allow_gaps=True, recode_gaps=False):
-        for span in map.spans:
+    def gapped_by_map_segment_iter(
+        self, segment_map, allow_gaps=True, recode_gaps=False
+    ) -> str:
+        if not allow_gaps and not segment_map.complete:
+            raise ValueError(f"gap(s) in map {segment_map}")
+
+        for span in segment_map.spans:
             if span.lost:
-                if allow_gaps:
-                    unknown = span.terminal or recode_gaps
-                    seg = "-?"[unknown] * span.length
-                else:
-                    raise ValueError(f"gap(s) in map {map}")
+                unknown = "?" if span.terminal or recode_gaps else "-"
+                seg = unknown * span.length
             else:
                 seg = str(self[span.start : span.end])
-                if span.reverse:
-                    complement = self.moltype.complement
-                    seg = [complement(base) for base in seg[::-1]]
-                    seg = "".join(seg)
+
             yield seg
 
     def gapped_by_map_motif_iter(self, map):
@@ -1294,12 +1299,10 @@ def gapped_by_map_motif_iter(self, map):
             yield from segment
 
     def gapped_by_map(self, map, recode_gaps=False):
-        # todo gah do we propagate annotations here?
         segments = self.gapped_by_map_segment_iter(map, True, recode_gaps)
-        new = self.__class__(
+        return self.__class__(
             "".join(segments), name=self.name, check=False, info=self.info
         )
-        return new
 
     def _mapped(self, map):
         # Called by generic __getitem__
@@ -1309,10 +1312,7 @@ def _mapped(self, map):
     def __repr__(self):
         myclass = f"{self.__class__.__name__}"
         myclass = myclass.split(".")[-1]
-        if len(self) > 10:
-            seq = f"{str(self)[:7]}... {len(self):,}"
-        else:
-            seq = str(self)
+        seq = f"{str(self)[:7]}... {len(self):,}" if len(self) > 10 else str(self)
         return f"{myclass}({seq})"
 
     def __getitem__(self, index):
@@ -1325,9 +1325,9 @@ def __getitem__(self, index):
         if hasattr(index, "map"):
             index = index.map
 
-        if isinstance(index, Map):
+        if isinstance(index, (FeatureMap, IndelMap)):
             new = self._mapped(index)
-            preserve_offset = not index.reverse
+            preserve_offset = True
 
         elif isinstance(index, slice) or _is_int(index):
             new = self.__class__(
@@ -1425,16 +1425,20 @@ def sliding_windows(self, window, step, start=None, end=None):
             for pos in range(start, end, step):
                 yield self[pos : pos + window]
 
-    def get_in_motif_size(self, motif_length=1, log_warnings=True):
+    @c3warn.deprecated_args(
+        "2024.6",
+        reason="argument name consistency",
+        old_new=(("log_warnings", "warn"),),
+    )
+    def get_in_motif_size(self, motif_length=1, warn=False):
         """returns sequence as list of non-overlapping motifs
 
         Parameters
         ----------
         motif_length
             length of the motifs
-        log_warnings
+        warn
             whether to notify of an incomplete terminal motif
-
         """
         seq = self._seq
         if isinstance(seq, SeqView):
@@ -1444,7 +1448,7 @@ def get_in_motif_size(self, motif_length=1, log_warnings=True):
 
         length = len(seq)
         remainder = length % motif_length
-        if remainder and log_warnings:
+        if remainder and warn:
             warnings.warn(
                 f'Dropped remainder "{seq[-remainder:]}" from end of sequence'
             )
@@ -1455,18 +1459,27 @@ def get_in_motif_size(self, motif_length=1, log_warnings=True):
 
     def parse_out_gaps(self):
         """returns Map corresponding to gap locations and ungapped Sequence"""
-        gapless = []
-        segments = []
-        nongap = re.compile(f"([^{re.escape('-')}]+)")
-        for match in nongap.finditer(str(self)):
-            segments.append(match.span())
-            gapless.append(match.group())
-        map = Map(segments, parent_length=len(self)).inverse()
+        gap = re.compile(f"[{re.escape(self.moltype.gap)}]+")
+        seq = str(self)
+        gap_pos = []
+        cum_lengths = []
+        for match in gap.finditer(seq):
+            pos = match.start()
+            gap_pos.append(pos)
+            cum_lengths.append(match.end() - pos)
+
+        gap_pos = array(gap_pos)
+        cum_lengths = array(cum_lengths).cumsum()
+        gap_pos[1:] = gap_pos[1:] - cum_lengths[:-1]
+
         seq = self.__class__(
-            "".join(gapless), name=self.get_name(), info=self.info, preserve_case=True
+            gap.sub("", seq), name=self.get_name(), info=self.info, preserve_case=True
+        )
+        indel_map = IndelMap(
+            gap_pos=gap_pos, cum_gap_lengths=cum_lengths, parent_length=len(seq)
         )
         seq.annotation_db = self.annotation_db
-        return map, seq
+        return indel_map, seq
 
     def replace(self, oldchar, newchar):
         """return new instance with oldchar replaced by newchar"""
@@ -1613,6 +1626,23 @@ def get_drawable(
         drawer.layout.update(xaxis=xaxis, yaxis=yaxis)
         return drawer
 
+    def parent_coordinates(self) -> Tuple[str, int, int, int]:
+        """returns seqid, start, stop, strand of this sequence on its parent
+
+        Notes
+        -----
+        seqid is the identifier of the parent. Returned coordinates are with
+        respect to the plus strand, irrespective of whether the sequence has
+        been reversed complemented or not.
+
+        Returns
+        -------
+        seqid, start, end, strand of this sequence on the parent. strand is either
+        -1 or 1.
+        """
+        strand = -1 if self._seq.reverse else 1
+        return self._seq.seqid, self._seq.parent_start, self._seq.parent_stop, strand
+
 
 class ProteinSequence(Sequence):
     """Holds the standard Protein sequence."""
@@ -1707,7 +1737,7 @@ def trim_stop_codon(
         if not gc.is_stop(end):
             return self
 
-        if not len(m.gaps()):
+        if not m.num_gaps:
             # has zero length if no gaps
             return self[:-3]
 
@@ -1877,9 +1907,19 @@ def _input_vals_neg_step(seqlen, start, stop, step):
 
 
 class SeqView:
-    __slots__ = ("seq", "start", "stop", "step", "_offset")
+    __slots__ = ("seq", "start", "stop", "step", "_offset", "_seqid", "_seq_len")
 
-    def __init__(self, seq, *, start: int = None, stop: int = None, step: int = None):
+    def __init__(
+        self,
+        seq,
+        *,
+        start: Optional[int] = None,
+        stop: Optional[int] = None,
+        step: Optional[int] = None,
+        offset: int = 0,
+        seqid: Optional[str] = None,
+        seq_len: Optional[int] = None,
+    ):
         if step == 0:
             raise ValueError("step cannot be 0")
         step = 1 if step is None else step
@@ -1889,8 +1929,12 @@ def __init__(self, seq, *, start: int = None, stop: int = None, step: int = None
         self.seq = seq
         self.start = start
         self.stop = stop
-        self._offset = 0
         self.step = step
+        self._offset = offset
+        self._seqid = seqid
+        if seq_len is not None and seq_len != len(seq):
+            raise AssertionError(f"{seq_len} != {len(self.seq)})")
+        self._seq_len = seq_len or len(self.seq)
 
     @property
     def offset(self) -> int:
@@ -1898,7 +1942,51 @@ def offset(self) -> int:
 
     @offset.setter
     def offset(self, value: int):
-        self._offset = value or 0
+        value = value or 0
+        self._offset = int(value)
+
+    @property
+    def seqid(self) -> str:
+        return self._seqid
+
+    @property
+    def seq_len(self) -> int:
+        return self._seq_len
+
+    @property
+    def parent_start(self) -> int:
+        """returns the start on the parent plus strand
+
+        Returns
+        -------
+        offset + start, taking into account whether reversed. Result
+        is positive.
+        """
+        if self.reverse:
+            # self.stop becomes the start, self.stop will be negative
+            assert self.stop < 0, "expected stop on reverse strand SeqView < 0"
+            start = self.stop + self.seq_len + 1
+        else:
+            start = self.start
+
+        return self.offset + start
+
+    @property
+    def parent_stop(self) -> int:
+        """returns the stop on the parent plus strand
+
+        Returns
+        -------
+        offset + stop, taking into account whether reversed. Result
+        is positive.
+        """
+        if self.reverse:
+            # self.start becomes the stop, self.start will be negative
+            assert self.start < 0, "expected start on reverse strand SeqView < 0"
+            stop = self.start + self.seq_len + 1
+        else:
+            stop = self.stop
+        return self.offset + stop
 
     @property
     def reverse(self):
@@ -1915,9 +2003,11 @@ def absolute_position(self, rel_index: int, include_boundary=False):
 
         Returns
         -------
-        the absolute index with respect to the coordinates of the sequence's annotations
-
+        the absolute index with respect to the coordinates of the self
+        including offset
         """
+        if not self:
+            return 0
 
         if rel_index < 0:
             raise IndexError("only positive indexing supported!")
@@ -1928,7 +2018,7 @@ def absolute_position(self, rel_index: int, include_boundary=False):
         # add offset and handle reversed views, now absolute relative to annotation coordinates
         offset = self.offset
         if self.reverse:
-            abs_index = offset + len(self.seq) + seq_index + 1
+            abs_index = offset + self.seq_len + seq_index + 1
         else:
             abs_index = offset + seq_index
 
@@ -1940,6 +2030,8 @@ def relative_position(self, abs_index, stop=False):
         NOTE: the returned value DOES NOT reflect python indexing. Importantly, negative values represent positions that
         precede the current view.
         """
+        if not self:
+            return 0
 
         if abs_index < 0:
             raise IndexError("Index must be +ve and relative to the + strand")
@@ -1948,7 +2040,7 @@ def relative_position(self, abs_index, stop=False):
             offset = self.offset
 
             if (
-                tmp := ((len(self.seq) - (abs_index - offset)) + self.start + 1)
+                tmp := (self.seq_len - abs_index + offset + self.start + 1)
             ) % self.step == 0 or stop:
                 rel_pos = tmp // abs(self.step)
             else:
@@ -2032,7 +2124,7 @@ def _get_forward_slice_from_forward_seqview_(self, slice_start, slice_stop, step
         # checking for zero-length slice
         if stop < start:
             return _zero_slice
-        if start > len(self.seq):
+        if start > self.seq_len:
             return _zero_slice
 
         return self.__class__(
@@ -2040,6 +2132,9 @@ def _get_forward_slice_from_forward_seqview_(self, slice_start, slice_stop, step
             start=start,
             stop=min(self.stop, stop),
             step=self.step * step,
+            offset=self.offset,
+            seqid=self.seqid,
+            seq_len=self.seq_len,
         )
 
     def _get_forward_slice_from_reverse_seqview_(self, slice_start, slice_stop, step):
@@ -2064,6 +2159,9 @@ def _get_forward_slice_from_reverse_seqview_(self, slice_start, slice_stop, step
             start=start,
             stop=max(self.stop, stop),
             step=self.step * step,
+            offset=self.offset,
+            seqid=self.seqid,
+            seq_len=self.seq_len,
         )
 
     def _get_reverse_slice(self, segment, step):
@@ -2083,30 +2181,29 @@ def _get_reverse_slice_from_forward_seqview_(self, slice_start, slice_stop, step
         # "true stop" adjust for if abs(stop-start) % step != 0
         # max possible start is "true stop" - step, because stop is not inclusive
         # "true stop" - step is converted to -ve index via subtracting len(self)
-        seq_len = len(self.seq)
         if slice_start >= len(self):
-            start = (self.start + len(self) * self.step - self.step) - seq_len
+            start = (self.start + len(self) * self.step - self.step) - self.seq_len
         elif slice_start >= 0:
-            start = (self.start + slice_start * self.step) - seq_len
+            start = (self.start + slice_start * self.step) - self.seq_len
         else:
             start = (
                 self.start
                 + len(self) * self.step
                 + slice_start * self.step
-                - len(self.seq)
+                - self.seq_len
             )
 
-        if slice_stop >= seq_len:
+        if slice_stop >= self.seq_len:
             return _zero_slice
 
         if slice_stop >= 0:
-            stop = self.start + (slice_stop * self.step) - seq_len
+            stop = self.start + (slice_stop * self.step) - self.seq_len
         else:
             stop = (
                 self.start
                 + (len(self) * self.step)
                 + (slice_stop * self.step)
-                - seq_len
+                - self.seq_len
             )
 
         if start >= 0 or stop >= 0:
@@ -2115,37 +2212,39 @@ def _get_reverse_slice_from_forward_seqview_(self, slice_start, slice_stop, step
         return self.__class__(
             self.seq,
             start=start,
-            stop=max(stop, self.start - len(self.seq) - 1),
+            stop=max(stop, self.start - self.seq_len - 1),
             step=self.step * step,
+            offset=self.offset,
+            seqid=self.seqid,
+            seq_len=self.seq_len,
         )
 
     def _get_reverse_slice_from_reverse_seqview_(self, slice_start, slice_stop, step):
         # max start is "true stop" + abs(step), because stop is not inclusive
         # "true stop" adjust for if abs(stop-start) % step != 0
-        seq_len = len(self.seq)
         if slice_start >= len(self):
-            start = seq_len + self.start + len(self) * self.step + abs(self.step)
+            start = self.seq_len + self.start + len(self) * self.step + abs(self.step)
         elif slice_start >= 0:
-            start = seq_len + (self.start + slice_start * self.step)
+            start = self.seq_len + (self.start + slice_start * self.step)
         else:
-            start = seq_len + (
+            start = self.seq_len + (
                 self.start + len(self) * self.step + slice_start * self.step
             )
 
         if slice_stop >= 0:
-            stop = seq_len + (self.start + slice_stop * self.step)
-            if stop <= seq_len + self.stop:
+            stop = self.seq_len + (self.start + slice_stop * self.step)
+            if stop <= self.seq_len + self.stop:
                 return _zero_slice
         else:
-            stop = seq_len + (
+            stop = self.seq_len + (
                 self.start + len(self) * self.step + slice_stop * self.step
             )
-            if stop > seq_len + self.start:
-                stop = seq_len + self.start + 1
+            if stop > self.seq_len + self.start:
+                stop = self.seq_len + self.start + 1
 
         # if -ve, it's an invalid slice becomes zero
         # checking for zero-length slice
-        if stop < start or start > seq_len or min(start, stop) < 0:
+        if stop < start or start > self.seq_len or min(start, stop) < 0:
             return _zero_slice
 
         return self.__class__(
@@ -2153,16 +2252,33 @@ def _get_reverse_slice_from_reverse_seqview_(self, slice_start, slice_stop, step
             start=start,
             stop=stop,
             step=self.step * step,
+            offset=self.offset,
+            seqid=self.seqid,
+            seq_len=self.seq_len,
         )
 
     def __getitem__(self, segment):
         if _is_int(segment):
             start, stop, step = self._get_index(segment)
-            return self.__class__(self.seq, start=start, stop=stop, step=step)
+            return self.__class__(
+                self.seq,
+                start=start,
+                stop=stop,
+                step=step,
+                offset=self.offset,
+                seqid=self.seqid,
+                seq_len=self.seq_len,
+            )
+
+        if segment.start is segment.stop is segment.step is None:
+            return self.copy(sliced=False)
 
         if len(self) == 0:
             return self
 
+        if segment.start is not None and segment.start == segment.stop:
+            return _zero_slice
+
         slice_step = 1 if segment.step is None else segment.step
 
         if slice_step > 0:
@@ -2182,7 +2298,13 @@ def replace(self, old, new):
         new_seq = self.seq.replace(old, new)
         if len(old) == len(new):
             return self.__class__(
-                new_seq, start=self.start, stop=self.stop, step=self.step
+                new_seq,
+                start=self.start,
+                stop=self.stop,
+                step=self.step,
+                offset=self.offset,
+                seqid=self.seqid,
+                seq_len=self.seq_len,
             )
 
         return self.__class__(new_seq)
@@ -2197,8 +2319,12 @@ def __str__(self) -> str:
         return self.value
 
     def __repr__(self) -> str:
-        seq = f"{self.seq[:10]}...{self.seq[-5:]}" if len(self.seq) > 15 else self.seq
-        return f"{self.__class__.__name__}(seq={seq!r}, start={self.start}, stop={self.stop}, step={self.step})"
+        seq = f"{self.seq[:10]}...{self.seq[-5:]}" if self.seq_len > 15 else self.seq
+        return (
+            f"{self.__class__.__name__}(seq={seq!r}, start={self.start}, "
+            f"stop={self.stop}, step={self.step}, offset={self.offset}, "
+            f"seqid={self.seqid!r}, seq_len={self.seq_len})"
+        )
 
     def to_rich_dict(self):
         # get the current state
@@ -2207,25 +2333,45 @@ def to_rich_dict(self):
         # step is sufficient
         data["init_args"] = {"step": self.step}
         if self.reverse:
-            adj = len(self.seq) + 1
+            adj = self.seq_len + 1
             start, stop = self.stop + adj, self.start + adj
-            offset = self.offset + start
         else:
             start, stop = self.start, self.stop
-            offset = self.offset + self.start
 
-        data["offset"] = offset
         data["init_args"]["seq"] = self.seq[start:stop]
+        data["init_args"]["offset"] = int(self.parent_start)
+        data["init_args"]["seqid"] = self.seqid
         return data
 
     @classmethod
     def from_rich_dict(cls, data: dict):
         init_args = data.pop("init_args")
-        offset = data.pop("offset", 0)
+        if "offset" in data:
+            init_args["offset"] = data.pop("offset")
         sv = cls(**init_args)
-        sv.offset = offset
         return sv
 
+    def copy(self, sliced=False):
+        """returns copy
+
+        Parameters
+        ----------
+        sliced
+            if True, the underlying sequence is truncated and the start/stop
+            adjusted
+        """
+        if not sliced:
+            return self.__class__(
+                self.seq,
+                start=self.start,
+                stop=self.stop,
+                step=self.step,
+                offset=self.offset,
+                seqid=self.seqid,
+                seq_len=self.seq_len,
+            )
+        return self.from_rich_dict(self.to_rich_dict())
+
 
 _zero_slice = SeqView(seq="")
 
@@ -2815,51 +2961,51 @@ class ArrayProteinWithStopSequence(ArraySequence):
 
 
 @singledispatch
-def _coerce_seq(data, preserve_case, checker):
+def _coerce_to_seqview(data, seqid, preserve_case, checker):
     from cogent3.core.alignment import Aligned
 
     if isinstance(data, Aligned):
-        return _coerce_seq(str(data), preserve_case, checker)
+        return _coerce_to_seqview(str(data), seqid, preserve_case, checker)
     raise NotImplementedError(f"{type(data)}")
 
 
-@_coerce_seq.register
-def _(data: SeqView, preserve_case, checker):
+@_coerce_to_seqview.register
+def _(data: SeqView, seqid, preserve_case, checker):
     return data
 
 
-@_coerce_seq.register
-def _(data: Sequence, preserve_case, checker):
-    return _coerce_seq(str(data), preserve_case, checker)
+@_coerce_to_seqview.register
+def _(data: Sequence, seqid, preserve_case, checker):
+    return _coerce_to_seqview(data._seq, seqid, preserve_case, checker)
 
 
-@_coerce_seq.register
-def _(data: ArraySequence, preserve_case, checker):
-    return _coerce_seq(str(data), preserve_case, checker)
+@_coerce_to_seqview.register
+def _(data: ArraySequence, seqid, preserve_case, checker):
+    return _coerce_to_seqview(str(data), seqid, preserve_case, checker)
 
 
-@_coerce_seq.register
-def _(data: str, preserve_case, checker):
+@_coerce_to_seqview.register
+def _(data: str, seqid, preserve_case, checker):
     if not preserve_case:
         data = data.upper()
     checker(data)
-    return SeqView(data)
+    return SeqView(data, seqid=seqid)
 
 
-@_coerce_seq.register
-def _(data: bytes, preserve_case, checker):
+@_coerce_to_seqview.register
+def _(data: bytes, seqid, preserve_case, checker):
     if not preserve_case:
         data = data.upper()
     data = data.decode("utf8")
     checker(data)
-    return SeqView(data)
+    return SeqView(data, seqid=seqid)
 
 
-@_coerce_seq.register
-def _(data: tuple, preserve_case, checker):
-    return _coerce_seq("".join(data), preserve_case, checker)
+@_coerce_to_seqview.register
+def _(data: tuple, seqid, preserve_case, checker):
+    return _coerce_to_seqview("".join(data), seqid, preserve_case, checker)
 
 
-@_coerce_seq.register
-def _(data: list, preserve_case, checker):
-    return _coerce_seq("".join(data), preserve_case, checker)
+@_coerce_to_seqview.register
+def _(data: list, seqid, preserve_case, checker):
+    return _coerce_to_seqview("".join(data), seqid, preserve_case, checker)
diff --git a/src/cogent3/core/tree.py b/src/cogent3/core/tree.py
index 2a76d50e5..4fc18c471 100644
--- a/src/cogent3/core/tree.py
+++ b/src/cogent3/core/tree.py
@@ -24,6 +24,7 @@
        from a node
     -  stem: the edge immediately preceeding a clade
 """
+
 from __future__ import annotations
 
 import json
diff --git a/src/cogent3/draw/dotplot.py b/src/cogent3/draw/dotplot.py
index 00c67c48e..967f6d769 100644
--- a/src/cogent3/draw/dotplot.py
+++ b/src/cogent3/draw/dotplot.py
@@ -1,3 +1,5 @@
+import numpy
+
 from cogent3.align.pycompare import (
     MatchedSeqPaths,
     SeqKmers,
@@ -27,14 +29,23 @@ def suitable_threshold(window, desired_probability):
     return matches
 
 
-def len_seq(span):
-    """length of a Annotatable map object"""
-    return len(span.nongap())
-
+def _ungapped_spans(
+    starts_ends: list[list[int, int]], align_length: int, aligned=False
+) -> numpy.ndarray:
+    """returns numpy array of [(non gap start, non gap stop), ...}"""
+    if len(starts_ends) == 0:
+        data = [(0, align_length)] if aligned else []
+        return numpy.array(data, dtype=int)
 
-def not_gap(span):
-    """whether a span corresponds to a non-gap"""
-    return len(span.gaps()) == 0
+    if starts_ends[0][0] != 0:
+        # does not start with a gap, so we adjust to get an ungapped span at
+        # the start
+        starts_ends = [(0, 0)] + starts_ends
+    if starts_ends[-1][1] != align_length:
+        # does not end with a gap, so we adjust to get an ungapped span at
+        # the end
+        starts_ends.append((align_length, align_length))
+    return numpy.array(starts_ends).flatten()[1:-1].reshape((len(starts_ends) - 1, 2))
 
 
 def _convert_input(seq, moltype):
@@ -59,40 +70,25 @@ def get_align_coords(map1, map2, aligned=False) -> MatchedSeqPaths:
     """sequence coordinates of aligned segments"""
     from cogent3.align.pycompare import segment
 
+    if aligned:
+        assert len(map1) == len(map2), "aligned sequences not equal length"
+
+    # we get the gap coordinates in alignment indices for both sequences
+    # sorting this allows us to trivially identify the alignment indices of
+    # ungapped segments,
+    # e.g. [(gap start 1, gap end 1), (gap start 2, gap end 2),...
+    # then [(gap end 1, gap start 2), ...
+    # is what we need to identify the segments for plotting
+    starts_ends = map1.get_gap_align_coordinates().tolist()
+    starts_ends.extend(map2.get_gap_align_coordinates().tolist())
+    starts_ends.sort()
+    starts_ends = _ungapped_spans(starts_ends, len(map1), aligned=aligned)
     paths = MatchedSeqPaths("Alignment")
-    if not_gap(map1) and not_gap(map2):
-        # no gaps
-        if aligned:
-            assert len(map1) == len(map2), "Aligned sequences inconsistent length"
-            paths[0].append((segment(0, len(map1)), segment(0, len(map2))))
-        return paths
-
-    assert len(map1) == len(map2), "aligned sequences not equal length"
-    # diagonals are places where both sequences are NOT gaps
-    # so we record start of a diagonal and when we hit a 'gap'
-    # in either sequence, we have the end of the diagonal
-
-    start_x = start_y = None
-    for i in range(len(map1)):
-        x_not_gap = not_gap(map1[i])
-        y_not_gap = not_gap(map2[i])
-        if x_not_gap and y_not_gap and start_x is None:
-            start_x = len_seq(map1[:i])
-            start_y = len_seq(map2[:i])
-        elif (not x_not_gap or not y_not_gap) and start_x is not None:
-            paths[start_y - start_x].append(
-                (
-                    segment(start_x, len_seq(map1[:i]) - 1),
-                    segment(start_y, len_seq(map2[:i]) - 1),
-                )
-            )
-            start_x = start_y = None
-
-    if start_x is not None:
-        paths[start_y - start_x].append(
-            (segment(start_x, len_seq(map1) - 1), segment(start_y, len_seq(map2) - 1))
-        )
-
+    for align_start, align_end in starts_ends:
+        # ends are inclusive for plotting
+        s1 = segment(map1.get_seq_index(align_start), map1.get_seq_index(align_end) - 1)
+        s2 = segment(map2.get_seq_index(align_start), map2.get_seq_index(align_end) - 1)
+        paths.append(s1, s2)
     return paths
 
 
@@ -129,8 +125,9 @@ def __init__(
             windows where the sequences are identical >= threshold are a match
         k : int
             size of k-mer to break sequences into. Larger values increase
-            speed but reduce resolution. If not specified, is computed as the
-            maximum of (window-threshold), (window % k) * k <= threshold.
+            speed but reduce resolution. If not specified, and
+            window == threshold, then k is set to window. Otherwise, it is
+            computed as the maximum of {threshold // (window - threshold), 5}.
         min_gap : int
             permitted gap for joining adjacent line segments, default is no gap
             joining
diff --git a/src/cogent3/draw/drawable.py b/src/cogent3/draw/drawable.py
index 2544f35fe..d919b46a1 100644
--- a/src/cogent3/draw/drawable.py
+++ b/src/cogent3/draw/drawable.py
@@ -777,19 +777,16 @@ def __call__(self, type_=None, name=None, coords=None, **kwargs):
 
             name = type_.name if type_.map.complete else f"{type_.name} (incomplete)"
             coords = type_.map.get_coordinates()
-            reverse = type_.map.get_covering_span().reverse
+            reverse = type_.reversed
             type_ = type_.biotype
         else:
-            if coords[0][0] > coords[-1][1]:
-                reverse = True
-            else:
-                reverse = False
+            reverse = coords[0][0] > coords[-1][1]
             if coords is None:
                 raise ValueError("No coordinates defined")
-        kwargs.update(dict(reverse=reverse))
+        kwargs |= dict(reverse=reverse)
 
         klass = self._shapes.get(type_.lower(), Rectangle)
-        color = self._colors.get(type_.lower(), None)
+        color = self._colors.get(type_.lower())
         if klass != Arrow:
             kwargs.pop("reverse", None)
 
diff --git a/src/cogent3/evolve/coevolution.py b/src/cogent3/evolve/coevolution.py
index 79f874453..1f6be97bf 100755
--- a/src/cogent3/evolve/coevolution.py
+++ b/src/cogent3/evolve/coevolution.py
@@ -622,7 +622,9 @@ def get_subalignments(aln, position, selections):
     result = []
     for s in aln.alphabet.to_indices(selections):
         seqs_to_keep = nonzero(aln.array_seqs[:, position] == s)[0]
-        result.append(aln.get_sub_alignment(seqs=seqs_to_keep))
+        sub_align = aln.get_sub_alignment(seqs=seqs_to_keep)
+        if sub_align is not None:
+            result.append(sub_align)
     return result
 
 
@@ -1945,9 +1947,9 @@ def filter_threshold_based_multiple_interdependency(
     else:
         # filter the rows and cols in a symmetric matrix
         for row_n in filtered_rows:
-            coevolution_matrix[row_n, :] = coevolution_matrix[
-                :, row_n
-            ] = DEFAULT_NULL_VALUE
+            coevolution_matrix[row_n, :] = coevolution_matrix[:, row_n] = (
+                DEFAULT_NULL_VALUE
+            )
 
     # return the result
     return coevolution_matrix
diff --git a/src/cogent3/evolve/likelihood_function.py b/src/cogent3/evolve/likelihood_function.py
index 64aed6862..46225c116 100644
--- a/src/cogent3/evolve/likelihood_function.py
+++ b/src/cogent3/evolve/likelihood_function.py
@@ -1,5 +1,6 @@
 import json
 import random
+import typing
 
 from collections import defaultdict
 from copy import deepcopy
@@ -8,6 +9,7 @@
 
 from cogent3._version import __version__
 from cogent3.core.alignment import ArrayAlignment
+from cogent3.core.tree import PhyloNode
 from cogent3.evolve import substitution_model
 from cogent3.evolve.simulate import AlignmentEvolver, random_sequence
 from cogent3.maths.matrix_exponential_integration import expected_number_subs
@@ -17,6 +19,7 @@
     paralinear_discrete_time,
 )
 from cogent3.recalculation.definition import ParameterController
+from cogent3.recalculation.scope import InvalidScopeError
 from cogent3.util import table
 from cogent3.util.dict_array import DictArrayTemplate
 from cogent3.util.misc import adjusted_gt_minprob, get_object_provenance
@@ -376,10 +379,8 @@ def get_rate_matrix_for_edge(self, name, calibrated=True, **kw):
                 length = self.get_param_value("length", edge=name, **kw)
                 array *= length
         except KeyError as err:
-            if err[0] == "Q" and name != "Q":
-                raise RuntimeError("rate matrix not known by this model")
-            else:
-                raise
+            raise InvalidScopeError from err
+
         return DictArrayTemplate(self._motifs, self._motifs).wrap(array)
 
     def _getLikelihoodValuesSummedAcrossAnyBins(self, locus=None):
@@ -564,7 +565,7 @@ def __str__(self):
         results = [title, lnL, nfp] + results if lnL else [title, nfp] + results
         return "\n".join(map(str, results))
 
-    def get_annotated_tree(self, length_as=None):
+    def get_annotated_tree(self, length_as: typing.Optional[str] = None) -> PhyloNode:
         """returns tree with model attributes on node.params
 
         length_as : str or None
@@ -582,15 +583,15 @@ def get_annotated_tree(self, length_as=None):
 
         is_discrete = isinstance(self.model, DiscreteSubstitutionModel)
 
-        if is_discrete and not length_as == "paralinear":
+        if is_discrete and length_as != "paralinear":
             raise ValueError(f"{length_as} invalid for discrete time process")
 
         assert length_as in ("ENS", "paralinear", None)
         d = self.get_param_value_dict(["edge"])
         lengths = d.pop("length", None)
         mprobs = self.get_motif_probs_by_node()
-        if not is_discrete:
-            ens = self.get_lengths_as_ens(motif_probs=mprobs)
+
+        ens = {} if is_discrete else self.get_lengths_as_ens(motif_probs=mprobs)
 
         plin = self.get_paralinear_metric(motif_probs=mprobs)
         if length_as == "ENS":
@@ -604,20 +605,46 @@ def get_annotated_tree(self, length_as=None):
                 edge.params["mprobs"] = mprobs[edge.name].to_dict()
                 continue
 
-            if not is_discrete:
-                edge.params["ENS"] = ens[edge.name]
-
+            edge.params["ENS"] = ens.get(edge.name)
             edge.params["length"] = lengths[edge.name]
             edge.params["paralinear"] = plin[edge.name]
             edge.params["mprobs"] = mprobs[edge.name].to_dict()
             for par in d:
-                val = d[par][edge.name]
+                val = d[par].get(edge.name)
                 if par == length_as:
                     val = ens[edge.name]
                 edge.params[par] = val
 
         return tree
 
+    def get_ens_tree(self) -> PhyloNode:
+        """returns tree with length as ENS
+
+        Notes
+        -----
+        The paralinear distance is added to node.params["paralinear"].
+
+        If it's a discrete-time model, branch lengths are set to None.
+
+        For a stationary model, branch lengths will be unchanged from
+        those values displayed in the statistics tables.
+        """
+        from cogent3.evolve.ns_substitution_model import (
+            DiscreteSubstitutionModel,
+        )
+
+        mprobs = self.get_motif_probs_by_node()
+        if isinstance(self.model, DiscreteSubstitutionModel):
+            raise TypeError("cannot get ENS for discrete-time models")
+
+        ens = self.get_lengths_as_ens(motif_probs=mprobs)
+
+        tree = self._tree.deepcopy()
+        for edge in tree.get_edge_vector(include_root=False):
+            edge.params["length"] = ens[edge.name]
+
+        return tree
+
     def get_motif_probs(self, edge=None, bin=None, locus=None, position=None):
         """
         Parameters
@@ -702,23 +729,22 @@ def get_paralinear_metric(self, motif_probs=None):
         motif_probs : dict or DictArray
             an item for each edge of the tree. Computed if not provided.
         """
-        from cogent3.evolve.ns_substitution_model import (
-            DiscreteSubstitutionModel,
-        )
-
-        is_discrete = isinstance(self.model, DiscreteSubstitutionModel)
-
         if motif_probs is None:
             motif_probs = self.get_motif_probs_by_node()
+
         plin = {}
         for edge in self.tree.get_edge_vector(include_root=False):
             parent_name = edge.parent.name
             pi = motif_probs[parent_name]
             P = self.get_psub_for_edge(edge.name)
-            if is_discrete:
+            try:
+                Q = self.get_rate_matrix_for_edge(edge.name, calibrated=False)
+            except InvalidScopeError:
+                Q = None
+
+            if Q is None:
                 para = paralinear_discrete_time(P.array, pi.array)
             else:
-                Q = self.get_rate_matrix_for_edge(edge.name, calibrated=False)
                 para = paralinear_continuous_time(P.array, pi.array, Q.array)
 
             plin[edge.name] = para
@@ -729,16 +755,31 @@ def get_lengths_as_ens(self, motif_probs=None):
         """returns {edge.name: ens, ...} where ens is the expected number of substitutions
 
         for a stationary Markov process, this is just branch length
+
         Parameters
         ----------
         motif_probs : dict or DictArray
             an item for each edge of the tree. Computed if not provided.
         """
+        from cogent3.evolve.ns_substitution_model import (
+            DiscreteSubstitutionModel,
+        )
+
         if motif_probs is None:
             motif_probs = self.get_motif_probs_by_node()
 
         edge_parent = self.tree.child_parent_map()
-        lengths = {e: self.get_param_value("length", edge=e) for e in edge_parent}
+        lengths = {}
+        for e in edge_parent:
+            try:
+                length = self.get_param_value("length", edge=e)
+            except (InvalidScopeError, KeyError):
+                length = None
+            lengths[e] = length
+
+        if isinstance(self.model, DiscreteSubstitutionModel):
+            return lengths
+
         if not isinstance(self.model, substitution_model.Stationary):
             ens = {}
             for e in edge_parent:
diff --git a/src/cogent3/evolve/likelihood_tree.py b/src/cogent3/evolve/likelihood_tree.py
index 77ae96b8b..cee985c3e 100644
--- a/src/cogent3/evolve/likelihood_tree.py
+++ b/src/cogent3/evolve/likelihood_tree.py
@@ -1,7 +1,6 @@
 """Leaf and Edge classes that can calculate their likelihoods.
 Each leaf holds a sequence.  Used by a likelihood function."""
 
-
 import numpy
 
 from . import likelihood_tree_numba as likelihood_tree
diff --git a/src/cogent3/evolve/motif_prob_model.py b/src/cogent3/evolve/motif_prob_model.py
index f091a95ef..66d56e44d 100644
--- a/src/cogent3/evolve/motif_prob_model.py
+++ b/src/cogent3/evolve/motif_prob_model.py
@@ -4,6 +4,8 @@
 
 import numpy
 
+import cogent3.util.warning as c3warn
+
 from cogent3.core.alphabet import Alphabet
 from cogent3.evolve.likelihood_tree import make_likelihood_tree_leaf
 from cogent3.recalculation.definition import CalcDefn, PartitionDefn
@@ -60,7 +62,7 @@ def count_motifs(self, alignment, include_ambiguity=False, recode_gaps=True):
                 result += count
         return result
 
-    def adapt_motif_probs(self, motif_probs, auto=False):
+    def adapt_motif_probs(self, motif_probs, **kwargs):
         motif_probs = adapt_motif_probs(self.get_input_alphabet(), motif_probs)
         return motif_probs
 
@@ -178,12 +180,15 @@ def make_motif_word_prob_defns(self):
         )
         return (monomer_probs, word_probs, mprobs_matrix)
 
-    def adapt_motif_probs(self, motif_probs, auto=False):
+    @c3warn.deprecated_args(
+        "2024.6", reason="use warn instead", discontinued="auto", stack_level=4
+    )
+    def adapt_motif_probs(self, motif_probs, warn=False, auto=False):
         try:
             motif_probs = adapt_motif_probs(self.monomer_alphabet, motif_probs)
         except ValueError:
             motif_probs = adapt_motif_probs(self.tuple_alphabet, motif_probs)
-            if not auto:
+            if warn:
                 warnings.warn("Motif probs over specified", stacklevel=5)
             motif_probs = self.calc_monomer_probs(motif_probs)
         return motif_probs
@@ -243,7 +248,7 @@ def set_param_controller_motif_probs(self, pc, motif_probs, **kw):
         for i, m in enumerate(motif_probs):
             pc.set_param_rule("psmprobs", value=m, position=str(i), **kw)
 
-    def adapt_motif_probs(self, motif_probs, auto=False):
+    def adapt_motif_probs(self, motif_probs, **kwargs):
         try:
             motif_probs = adapt_motif_probs(self.monomer_alphabet, motif_probs)
         except ValueError:
diff --git a/src/cogent3/evolve/parameter_controller.py b/src/cogent3/evolve/parameter_controller.py
index d602cf334..1d3260470 100644
--- a/src/cogent3/evolve/parameter_controller.py
+++ b/src/cogent3/evolve/parameter_controller.py
@@ -3,9 +3,7 @@
 parameters involved in a maximum-likelihood based tree analysis.
 """
 
-
 import pickle
-import warnings
 
 import numpy
 
@@ -143,6 +141,7 @@ def set_motif_probs_from_data(
         include_ambiguity=False,
         is_independent=None,
         auto=False,
+        warn=False,
         pseudocount=None,
         **kwargs,
     ):
@@ -159,6 +158,7 @@ def set_motif_probs_from_data(
             is_constant=is_constant,
             is_independent=is_independent,
             auto=auto,
+            warn=warn,
             **kwargs,
         )
 
@@ -170,9 +170,10 @@ def set_motif_probs(
         is_constant=None,
         is_independent=None,
         auto=False,
+        warn=False,
         **kwargs,
     ):
-        motif_probs = self.model.adapt_motif_probs(motif_probs, auto=auto)
+        motif_probs = self.model.adapt_motif_probs(motif_probs, warn=warn)
         motif_probs = adjusted_gt_minprob(motif_probs, minprob=1e-6)
         if is_constant is None:
             is_constant = not self.optimise_motif_probs
@@ -341,6 +342,7 @@ def set_param_rule(
         lower=None,
         init=None,
         upper=None,
+        warn=False,
         **scope_info,
     ):
         """Define a model constraint for par_name. Parameters can be set
@@ -363,6 +365,8 @@ def set_param_rule(
             the name(s) of the bin to apply rule.
         locus, loci
             the name of the locus/loci to apply rule.
+        warn
+            show warnings when numerical approximations used
         **scope_info
             tree scope arguments
 
@@ -389,18 +393,15 @@ def set_param_rule(
             ("motif", "motifs"),
         ]:
             if single in scope_info:
-                v = scope_info.pop(single)
-                if v:
+                if v := scope_info.pop(single):
                     assert isinstance(v, str), f"{plural}=, maybe?"
                     assert plural not in scope_info
                     scopes[single] = [v]
             elif plural in scope_info:
-                v = scope_info.pop(plural)
-                if v:
+                if v := scope_info.pop(plural):
                     scopes[single] = v
 
-        edges = self._process_scope_info(**scope_info)
-        if edges:
+        if edges := self._process_scope_info(**scope_info):
             scopes["edge"] = edges
 
         if is_constant:
@@ -409,7 +410,14 @@ def set_param_rule(
             assert not value
             value = init
         self.assign_all(
-            par_name, scopes, value, lower, upper, is_constant, is_independent
+            par_name,
+            scopes,
+            value,
+            lower,
+            upper,
+            is_constant,
+            is_independent,
+            warn=warn,
         )
 
     def set_local_clock(self, tip1name, tip2name):
@@ -587,4 +595,4 @@ def set_pogs(self, leaves, locus=None):
                     [pog.leaf.get_motif_counts() for pog in list(leaves.values())], 0
                 )
                 mprobs = counts / (1.0 * sum(counts))
-                self.set_motif_probs(mprobs, locus=locus, is_constant=True, auto=True)
+                self.set_motif_probs(mprobs, locus=locus, is_constant=True, warn=False)
diff --git a/src/cogent3/evolve/substitution_model.py b/src/cogent3/evolve/substitution_model.py
index 9c3044b48..c9b0b6b24 100644
--- a/src/cogent3/evolve/substitution_model.py
+++ b/src/cogent3/evolve/substitution_model.py
@@ -28,6 +28,7 @@
 >>> model.setparameterrules({'alpha': model.istransition})
 >>> parameter_controller = model.make_likelihood_function(tree)
 """
+
 from __future__ import annotations
 
 import json
@@ -314,6 +315,7 @@ def make_likelihood_function(
         digits: typing.Optional[int] = None,
         space: typing.Optional[str] = None,
         default_length: float = 1.0,
+        warn=False,
         **kw,
     ):
         """
@@ -343,6 +345,8 @@ def make_likelihood_function(
         default_length
             default branch length when provided tree has no (or zero) lengths,
             noting that a positive nonzero is required.
+        warn
+            show warnings when numerical approximations used
         kw
             other keyword args passed through to the likelihood function class
         """
@@ -366,7 +370,7 @@ def make_likelihood_function(
 
         if self.motif_probs is not None:
             result.set_motif_probs(
-                self.motif_probs, is_constant=not optimise_motif_probs, auto=True
+                self.motif_probs, is_constant=not optimise_motif_probs, warn=warn
             )
 
         if expm is None:
@@ -403,8 +407,8 @@ def make_alignment_defn(self, model):
         # to retrieve the correct (adapted) alignment.
         return AlignmentAdaptDefn(model, align)
 
-    def adapt_motif_probs(self, motif_probs, auto=False):
-        return self.mprob_model.adapt_motif_probs(motif_probs, auto=auto)
+    def adapt_motif_probs(self, motif_probs, **kwargs):
+        return self.mprob_model.adapt_motif_probs(motif_probs, **kwargs)
 
     def calc_monomer_probs(self, word_probs):
         # Not presently used, always go monomer->word instead
diff --git a/src/cogent3/format/fasta.py b/src/cogent3/format/fasta.py
index 0a4ebf216..28a3bf375 100644
--- a/src/cogent3/format/fasta.py
+++ b/src/cogent3/format/fasta.py
@@ -2,17 +2,45 @@
 """Writer for FASTA sequence format
 """
 
+from typing import Optional
+
 from cogent3.format.util import _AlignmentFormatter
 
 
-def alignment_to_fasta(alignment_dict, block_size=60, order=None):
-    """Returns a Fasta string given an alignment."""
+def alignment_to_fasta(
+    alignment_dict: dict[str, str],
+    block_size: int = 60,
+    order: Optional[list[str]] = None,
+) -> str:
+    """Returns a Fasta string given an alignment.
+
+    Parameters
+        ----------
+        alignment_dict
+            dict of seq_name
+        block_size
+            the sequence length to write to each line,
+            by default 60
+        order
+            optional list of sequence names, which order to print in.
+            Assumes complete and correct list of names,
+            by default None
+
+        Returns
+        -------
+        The alignment in the Fasta format.
+    """
     order = order or []
     return FastaFormatter().format(alignment_dict, block_size, order)
 
 
 class FastaFormatter(_AlignmentFormatter):
-    def format(self, alignment_dict, block_size, order):
+    def format(
+        self,
+        alignment_dict: dict[str, str],
+        block_size: int = 60,
+        order: Optional[list[str]] = None,
+    ) -> str:
         """Format the alignment to Fasta.
 
         Parameters
@@ -21,12 +49,15 @@ def format(self, alignment_dict, block_size, order):
             dict of seq_name
         block_size
             the sequence length to write to each line,
-            default is 60
+            by default 60
         order
-            optional list of sequence names, which order to
-            print in.
+            optional list of sequence names, which order to print in.
+            Assumes complete and correct list of names,
+            by default None
 
-        (Assumes complete and correct list of names)
+        Returns
+        -------
+        The alignment in the Fasta format.
         """
         # setup
         if not order:
diff --git a/src/cogent3/maths/distance_transform.py b/src/cogent3/maths/distance_transform.py
index fe3b27720..7142f7e84 100644
--- a/src/cogent3/maths/distance_transform.py
+++ b/src/cogent3/maths/distance_transform.py
@@ -73,18 +73,7 @@
     nan_to_num,
 )
 from numpy import ndim as rank
-from numpy import (
-    ravel,
-    seterr,
-    shape,
-    sqrt,
-    square,
-    sum,
-    take,
-    union1d,
-    where,
-    zeros,
-)
+from numpy import ravel, seterr, shape, sqrt, square, sum, take, where, zeros
 from numpy.linalg import norm
 
 
diff --git a/src/cogent3/maths/matrix_exponential_integration.py b/src/cogent3/maths/matrix_exponential_integration.py
index 905adbe10..adfe5930a 100644
--- a/src/cogent3/maths/matrix_exponential_integration.py
+++ b/src/cogent3/maths/matrix_exponential_integration.py
@@ -24,7 +24,6 @@ def __repr__(self):
 
 
 class VanLoanIntegratingExponentiator(_Exponentiator):
-
     """An exponentiator that evaluates int_0^t exp(Q*s)ds * R
     using the method of Van Loan [1]. Complexity is that of the Exponentiator.
     [1] Van Loan, C. F. (1978). Computing integrals involving the matrix
@@ -58,15 +57,17 @@ def __call__(self, t=1.0):
 
 
 class VonBingIntegratingExponentiator(_Exponentiator):
-
     """An exponentiator that evaluates int_0^t exp(Q*s)ds
-    using the method of Von Bing."""
+    using the method of Von Bing Yap (Personal Communication)."""
 
     def __init__(self, Q):
         """
-        Q -- a diagonisable matrix.
+        Parameters
+        ----------
+        Q
+            a diagonisable matrix.
         """
-        super(VonBingIntegratingExponentiator, self).__init__(Q)
+        super().__init__(Q)
         self.roots, self.evT = eig(Q)
         self.evI = inv(self.evT.T)
         # Remove following check if performance is a concern
diff --git a/src/cogent3/maths/optimisers.py b/src/cogent3/maths/optimisers.py
index 8cef102fa..2c9f177f2 100644
--- a/src/cogent3/maths/optimisers.py
+++ b/src/cogent3/maths/optimisers.py
@@ -1,5 +1,6 @@
 """Local or Global-then-local optimisation with progress display
 """
+
 from __future__ import annotations
 
 import warnings
@@ -10,6 +11,7 @@
 import numpy
 
 from cogent3.util import progress_display as UI
+from cogent3.util import warning as c3warn
 
 from .scipy_optimisers import Powell
 from .simannealingoptimiser import SimulatedAnnealing
@@ -138,6 +140,9 @@ def nf(x):
     return maximise(nf, *args, **kw)
 
 
+@c3warn.deprecated_args(
+    "2024.6", reason="has no effect in this function", discontinued="limit_action"
+)
 @UI.display_wrap
 def maximise(
     f,
@@ -148,23 +153,58 @@ def maximise(
     interval=None,
     max_restarts=None,
     max_evaluations=None,
-    limit_action="warn",
     tolerance=1e-6,
     global_tolerance=1e-1,
     ui=None,
     return_eval_count=False,
+    warn=False,
     **kw,
 ):
-    """Find input values that optimise this function.
+    """
+    Find input values that optimise this function.
     'local' controls the choice of optimiser, the default being to run
     both the global and local optimisers. 'filename' and 'interval'
     control checkpointing.  Unknown keyword arguments get passed on to
     the global optimiser.
+    Parameters
+    ----------
+    f
+        callable
+    xinit
+        initial parameter values
+    bounds
+        (upper, lower) bounds with a value in each for each element of xinit
+    local
+        Controls which optimiser(s) to apply. If True, uses Powell local
+        optimiser only. If False, uses only the global (Simulated Annealing)
+        optimiser. If None, uses both global and local.
+    filename
+        file to checkpoint optimisatiuon results to.
+    interval
+        time expressed in seconds
+    max_restarts
+        maximum number of times to try optimisation
+    max_evaluations
+        maximum number of function evaluations to perform
+    tolerance
+        exit condition for local optimiser
+    global_tolerance
+        exit condition for global optimiser
+    return_eval_count
+        number of times function evaluated
+    warn
+        whether to report unused variables
+    kw
+        kwargs passed through to global optimiser only
+
+    Returns
+    -------
+    The vector of values maximising f. Optionally returns the number of
+    function evaluations.
     """
     do_global = (not local) or local is None
     do_local = local or local is None
 
-    assert limit_action in ["ignore", "warn", "raise", "error"]
     (get_best, f) = limited_use(f, max_evaluations)
 
     x = numpy.array(xinit, float)
@@ -173,7 +213,7 @@ def maximise(
         x = numpy.atleast_1d(x)
 
     if bounds is not None:
-        (upper, lower) = bounds
+        upper, lower = bounds
         if upper is not None or lower is not None:
             if upper is None:
                 upper = numpy.inf
@@ -183,7 +223,10 @@ def maximise(
     try:
         fval = f(x)
     except (ArithmeticError, ParameterOutOfBoundsError) as detail:
-        raise ValueError(f"Initial parameter values must be valid {repr(detail.args)}")
+        raise ValueError(
+            f"Initial parameter values must be valid {detail.args!r}"
+        ) from detail
+
     if not numpy.isfinite(fval):
         raise ValueError(
             f"Initial parameter values must evaluate to a finite value, not {fval}. {x}"
@@ -201,8 +244,8 @@ def maximise(
             x = opt.maximise(f, x, tolerance=gtol, show_remaining=callback, **kw)
         else:
             gend = 0.0
-            for k in kw:
-                warnings.warn("Unused arg for local alignment: " + k)
+            if warn:
+                warnings.warn(f"Unused args for local optimisation: {kw}")
 
         # Local optimisation
         if do_local:
@@ -218,7 +261,7 @@ def maximise(
             )
     finally:
         # ensure state of calculator reflects optimised result, or
-        # partialy optimised result if exiting on an exception.
+        # partially optimised result if exiting on an exception.
         (f, x, evals) = get_best()
 
     # ... and returning this info the obvious way keeps this function
diff --git a/src/cogent3/maths/stats/distribution.py b/src/cogent3/maths/stats/distribution.py
index 9c25fc3a7..7a891b69e 100644
--- a/src/cogent3/maths/stats/distribution.py
+++ b/src/cogent3/maths/stats/distribution.py
@@ -12,10 +12,7 @@
     MACHEP,
     MAXNUM,
     PI,
-    SQRTH,
     betai,
-    erf,
-    erfc,
     expm1,
     fix_rounding_error,
     igam,
@@ -26,6 +23,7 @@
     log1p,
     ndtri,
 )
+from cogent3.util import warning as c3warn
 
 
 # ndtri import b/c it should be available via this module
@@ -46,27 +44,33 @@ def tprob(x, df):
     return 2 * t.sf(abs(x), df)
 
 
-def poisson_high(successes, mean):
-    """Returns right tail of Poission distribution, Pr(X > x).
-
-    successes ranges from 0 to infinity. mean must be positive.
-    """
+@c3warn.deprecated_callable(
+    version="2024.9",
+    reason="use scipy.stats.poisson.sf() instead",
+    is_discontinued=True,
+)
+def poisson_high(successes, mean):  # pragma: no cover
+    """being removed"""
     return pdtrc(successes, mean)
 
 
-def poisson_low(successes, mean):
-    """Returns left tail of Poisson distribution, Pr(X <= x).
-
-    successes ranges from 0 to infinity. mean must be positive.
-    """
+@c3warn.deprecated_callable(
+    version="2024.9",
+    reason="use scipy.stats.poisson.cdf() instead",
+    is_discontinued=True,
+)
+def poisson_low(successes, mean):  # pragma: no cover
+    """being removed"""
     return pdtr(successes, mean)
 
 
-def poisson_exact(successes, mean):
-    """Returns Poisson probablity for exactly Pr(X=successes).
-
-    Formula is e^-(mean) * mean^(successes) / (successes)!
-    """
+@c3warn.deprecated_callable(
+    version="2024.9",
+    reason="use scipy.stats.poisson.pmf() instead",
+    is_discontinued=True,
+)
+def poisson_exact(successes, mean):  # pragma: no cover
+    """being removed"""
     if successes == 0:
         return pdtr(0, mean)
     elif successes < mean:  # use left tail
diff --git a/src/cogent3/maths/stats/kendall.py b/src/cogent3/maths/stats/kendall.py
index 1bf2beb16..047f12d4d 100644
--- a/src/cogent3/maths/stats/kendall.py
+++ b/src/cogent3/maths/stats/kendall.py
@@ -5,7 +5,6 @@
 Translated from R 2.5 by Gavin Huttley
 """
 
-
 from numpy import array, floor, sqrt
 
 from cogent3.maths.stats.distribution import zprob
diff --git a/src/cogent3/maths/stats/special.py b/src/cogent3/maths/stats/special.py
index 18a8b2a33..d71b18826 100644
--- a/src/cogent3/maths/stats/special.py
+++ b/src/cogent3/maths/stats/special.py
@@ -4,6 +4,8 @@
 
 from numpy import exp, floor, log, sin, sqrt
 
+from cogent3.util import warning as c3warn
+
 
 log_epsilon = 1e-6  # for threshold in log/exp close to 1
 # For IEEE arithmetic (IBMPC):
@@ -58,7 +60,10 @@ def one_minus_exp(x):
         return 1 - exp(x)
 
 
-def permutations(n, k):
+@c3warn.deprecated_callable(
+    version="2024.9", reason="use scipy.special.perm() instead", is_discontinued=True
+)
+def permutations(n, k):  # pragma: no cover
     """Returns the number of ways of choosing k items from n, in order.
 
     Defined as n!/(n-k)!.
@@ -76,7 +81,10 @@ def permutations(n, k):
         return exp(ln_permutations(n, k))
 
 
-def permutations_exact(n, k):
+@c3warn.deprecated_callable(
+    version="2024.9", reason="use scipy.special.perm() instead", is_discontinued=True
+)
+def permutations_exact(n, k):  # pragma: no cover
     """Calculates permutations by integer division.
 
     Preferred method for small permutations, but slow on larger ones.
@@ -89,7 +97,10 @@ def permutations_exact(n, k):
     return product
 
 
-def ln_permutations(n, k):
+@c3warn.deprecated_callable(
+    version="2024.9", reason="use scipy.special.perm() instead", is_discontinued=True
+)
+def ln_permutations(n, k):  # pragma: no cover
     """Calculates permutations by difference in log of gamma function.
 
     Preferred method for large permutations, but slow on smaller ones.
@@ -99,7 +110,10 @@ def ln_permutations(n, k):
     return lgam(n + 1) - lgam(n - k + 1)
 
 
-def combinations(n, k):
+@c3warn.deprecated_callable(
+    version="2024.9", reason="Use scipy.special.comb() instead", is_discontinued=True
+)
+def combinations(n, k):  # pragma: no cover
     """Returns the number of ways of choosing k items from n, in order.
 
     Defined as n!/(k!(n-k)!).
@@ -118,7 +132,10 @@ def combinations(n, k):
         return exp(ln_combinations(n, k))
 
 
-def combinations_exact(n, k):
+@c3warn.deprecated_callable(
+    version="2024.9", reason="Use scipy.special.comb() instead", is_discontinued=True
+)
+def combinations_exact(n, k):  # pragma: no cover
     """Calculates combinations by integer division.
 
     Preferred method for small combinations, but slow on larger ones.
@@ -146,7 +163,10 @@ def combinations_exact(n, k):
     return product
 
 
-def ln_combinations(n, k):
+@c3warn.deprecated_callable(
+    version="2024.9", reason="Use scipy.special.comb() instead", is_discontinued=True
+)
+def ln_combinations(n, k):  # pragma: no cover
     """Calculates combinations by difference in log of gamma function.
 
     Preferred method for large combinations, but slow on smaller ones.
@@ -167,12 +187,15 @@ def ln_binomial(successes, trials, prob):
 
     Note: no error checking (expects to be called through binomial_exact())
     """
+    from scipy.special import loggamma
+
     prob = fix_rounding_error(prob)
-    return (
-        ln_combinations(trials, successes)
-        + successes * log(prob)
-        + (trials - successes) * log(1.0 - prob)
+    ln_comb = (
+        loggamma(trials + 1)
+        - loggamma(successes + 1)
+        - loggamma(trials - successes + 1)
     )
+    return ln_comb + successes * log(prob) + (trials - successes) * log(1.0 - prob)
 
 
 # Translations of functions from Cephes Math Library, by Stephen L. Moshier
@@ -248,16 +271,26 @@ def polevl(x, coef):
 ]
 
 
-def erf(a):
-    """Returns the error function of a: see Cephes docs."""
+@c3warn.deprecated_callable(
+    version="2024.9",
+    reason="use scipy.special.erf(x, out=None) instead",
+    is_discontinued=True,
+)
+def erf(a):  # pragma: no cover
+    """being removed"""
     if abs(a) > 1:
         return 1 - erfc(a)
     z = a * a
     return a * polevl(z, ZT) / polevl(z, ZU)
 
 
-def erfc(a):
-    """Returns the complement of the error function of a: see Cephes docs."""
+@c3warn.deprecated_callable(
+    version="2024.9",
+    reason="use scipy.special.erfc(z, out=None) instead",
+    is_discontinued=True,
+)
+def erfc(a):  # pragma: no cover
+    """being removed"""
     if a < 0:
         x = -a
     else:
diff --git a/src/cogent3/maths/util.py b/src/cogent3/maths/util.py
index 15c5730d4..fc07a3a61 100644
--- a/src/cogent3/maths/util.py
+++ b/src/cogent3/maths/util.py
@@ -1,5 +1,6 @@
 """Provides small utility functions for numpy arrays.
 """
+
 import numpy
 
 from numpy import array, clip, cumsum, searchsorted, sort, sum
diff --git a/src/cogent3/parse/blast.py b/src/cogent3/parse/blast.py
index d58dfc48a..ebc58fa98 100644
--- a/src/cogent3/parse/blast.py
+++ b/src/cogent3/parse/blast.py
@@ -1,5 +1,6 @@
 """Parsers for blast, psi-blast and blat.
 """
+
 from cogent3.parse.record_finder import (
     DelimitedRecordFinder,
     LabeledRecordFinder,
diff --git a/src/cogent3/parse/cigar.py b/src/cogent3/parse/cigar.py
index 77051e554..0abfabc2d 100644
--- a/src/cogent3/parse/cigar.py
+++ b/src/cogent3/parse/cigar.py
@@ -18,14 +18,14 @@
 import re
 
 from cogent3 import DNA, make_aligned_seqs
-from cogent3.core.location import LostSpan, Map, Span
+from cogent3.core.location import IndelMap, LostSpan, Span
 
 
 _pattern = re.compile("([0-9]*)([DM])")
 
 
 def map_to_cigar(map):
-    """convert a Map into a cigar string"""
+    """convert a IndelMap into a cigar string"""
     cigar = ""
     for span in map.spans:
         if isinstance(span, Span):
@@ -39,7 +39,7 @@ def map_to_cigar(map):
 
 
 def cigar_to_map(cigar_text):
-    """convert cigar string into Map"""
+    """convert cigar string into IndelMap"""
     assert "I" not in cigar_text
     spans, posn = [], 0
     for n, c in _pattern.findall(cigar_text):
@@ -49,7 +49,7 @@ def cigar_to_map(cigar_text):
             posn += n
         else:
             spans.append(LostSpan(n))
-    return Map(spans=spans, parent_length=posn)
+    return IndelMap.from_spans(spans=spans, parent_length=posn)
 
 
 def aligned_from_cigar(cigar_text, seq, moltype=DNA):
@@ -62,16 +62,18 @@ def aligned_from_cigar(cigar_text, seq, moltype=DNA):
 
 def _slice_by_aln(map, left, right):
     slicemap = map[left:right]
-    location = [slicemap.start, slicemap.end] if hasattr(slicemap, "start") else []
+    location = [
+        map.get_seq_index(left),
+        map.get_seq_index(right),
+    ]
     return slicemap, location
 
 
 def _slice_by_seq(map, start, end):
-    re_map = map.inverse()
-    slicemap = re_map[start:end]
-    aln_start, aln_end = slicemap.start, slicemap.end
-    new_map = map[aln_start:aln_end]
-    return new_map, [aln_start, aln_end]
+    # start, end are in sequence coords
+    aln_start = map.get_align_index(start)
+    aln_end = map.get_align_index(end, slice_stop=True)
+    return map[aln_start:aln_end], [aln_start, aln_end]
 
 
 def _remap(map):
@@ -88,7 +90,7 @@ def _remap(map):
                 span.end = span.end - start
                 length = span.end
             spans.append(span)
-        new_map = Map(spans=spans, parent_length=length)
+        new_map = IndelMap(spans=spans, parent_length=length)
     return new_map
 
 
@@ -136,10 +138,11 @@ def CigarParser(
         ]:
             m, seq_loc = slice_cigar(cigars[seqname], aln_loc[0], aln_loc[1])
             if seq_loc:
-                seq = seqs[seqname]
+                start, stop = seq_loc
+                seq = seqs[seqname][start:stop]
                 if isinstance(seq, str):
                     seq = moltype.make_seq(seq)
-                data[seqname] = seq[seq_loc[0] : seq_loc[1]].gapped_by_map(m)
+                data[seqname] = seq.gapped_by_map(m)
             else:
                 data[seqname] = DNA.make_seq("-" * (aln_loc[1] - aln_loc[0]))
     return make_aligned_seqs(data)
diff --git a/src/cogent3/parse/cogent3_json.py b/src/cogent3/parse/cogent3_json.py
index be4ace2e4..ceaa580dd 100644
--- a/src/cogent3/parse/cogent3_json.py
+++ b/src/cogent3/parse/cogent3_json.py
@@ -1,5 +1,6 @@
 """Supports JSON load/read/write operations on major Cogent3 objects.
 """
+
 import json
 
 from cogent3.app.data_store import load_record_from_json
diff --git a/src/cogent3/parse/fasta.py b/src/cogent3/parse/fasta.py
index 255228804..cb32c834d 100644
--- a/src/cogent3/parse/fasta.py
+++ b/src/cogent3/parse/fasta.py
@@ -1,5 +1,6 @@
 """Parsers for FASTA and related formats.
 """
+
 import os
 import re
 import typing
diff --git a/src/cogent3/parse/gbseq.py b/src/cogent3/parse/gbseq.py
index cf0b501f5..a917dbd2c 100644
--- a/src/cogent3/parse/gbseq.py
+++ b/src/cogent3/parse/gbseq.py
@@ -5,7 +5,7 @@
 import io
 import xml.dom.minidom
 
-from cogent3.core import annotation, moltype
+from cogent3.core import location, moltype
 
 
 """
@@ -64,10 +64,10 @@ def GbSeqXmlParser(doc):
 
         seq = alphabet.make_seq(raw_string, name=name)
 
-        all = annotation.Map([(0, len(seq))], parent_length=len(seq))
-        seq.add_feature(
-            biotype="source", name=name, spans=all.get_coordinates(), strand=all.reverse
+        feat = location.FeatureMap.from_locations(
+            locations=[(0, len(seq))], parent_length=len(seq)
         )
+        seq.add_feature(biotype="source", name=name, spans=feat.get_coordinates())
 
         organism = str(
             record.getElementsByTagName("GBSeq_organism")[0].childNodes[0].nodeValue
@@ -107,7 +107,7 @@ def GbSeqXmlParser(doc):
                         .nodeValue
                     )
                     spans.append((point - 1, point))
-            if spans == []:
+            if not spans:
                 spans = [(0, len(seq))]
             for qualifier in feature.getElementsByTagName("GBQualifier"):
                 qname = (
@@ -122,7 +122,7 @@ def GbSeqXmlParser(doc):
                         .nodeValue
                     )
             seq.add_feature(biotype=key, name=feature_name, spans=spans)
-        yield (name, seq)
+        yield name, seq
 
 
 def parse(*args):
diff --git a/src/cogent3/parse/genbank.py b/src/cogent3/parse/genbank.py
index 3757fe1d6..a343ceb1a 100644
--- a/src/cogent3/parse/genbank.py
+++ b/src/cogent3/parse/genbank.py
@@ -9,7 +9,6 @@
     DelimitedRecordFinder,
     LabeledRecordFinder,
 )
-from cogent3.util import warning as c3warn
 
 
 maketrans = str.maketrans
diff --git a/src/cogent3/parse/locuslink.py b/src/cogent3/parse/locuslink.py
index bc3242246..80fa61931 100644
--- a/src/cogent3/parse/locuslink.py
+++ b/src/cogent3/parse/locuslink.py
@@ -39,6 +39,7 @@
 PHENOTYPE_ID,BUTTON and LINK) refer to the same item (i.e. MAP[0] and MAPLINK[0]
 are probably a map and its corresponding link).
 """
+
 from cogent3.parse.record import (
     DelimitedSplitter,
     FieldWrapper,
diff --git a/src/cogent3/parse/newick.py b/src/cogent3/parse/newick.py
index 4ae28636d..2813cf84f 100644
--- a/src/cogent3/parse/newick.py
+++ b/src/cogent3/parse/newick.py
@@ -66,7 +66,7 @@ def tokens(self):
         closing_quote_token = None
         in_comment = False
         comment = []
-        splits = re.split("""([\\t ]+|\\n|''|""|[]['"(),:;\[\]])""", self.text)
+        splits = re.split(r"""([\t ]+|\n|''|""|[]['"(),:;\[\]])""", self.text)
         for token in splits + [EOT]:
             label_complete = False
             token_consumed = True
diff --git a/src/cogent3/parse/record_finder.py b/src/cogent3/parse/record_finder.py
index 5cc3e64e5..9b9ce11d4 100644
--- a/src/cogent3/parse/record_finder.py
+++ b/src/cogent3/parse/record_finder.py
@@ -12,6 +12,7 @@
 DelimitedRecordFinder except that it accept a is_tail function instead of a
 str.  Note that its default constuctor is rstrip instead of strip.
 """
+
 from cogent3.parse.record import RecordError
 
 
diff --git a/src/cogent3/parse/tinyseq.py b/src/cogent3/parse/tinyseq.py
index 3b59ae61a..61bfa6930 100644
--- a/src/cogent3/parse/tinyseq.py
+++ b/src/cogent3/parse/tinyseq.py
@@ -1,11 +1,11 @@
-#!/usr/bin/env python
 """Parser for NCBI Tiny Seq XML format.
 DOCTYPE TSeqSet PUBLIC "-//NCBI//NCBI TSeq/EN" "http://www.ncbi.nlm.nih.gov/dtd/NCBI_TSeq.dtd"
 """
+
 import io
 import xml.dom.minidom
 
-from cogent3.core import annotation, moltype
+from cogent3.core import moltype
 
 
 """
diff --git a/src/cogent3/recalculation/definition.py b/src/cogent3/recalculation/definition.py
index 346e08b3c..04b9b48c7 100644
--- a/src/cogent3/recalculation/definition.py
+++ b/src/cogent3/recalculation/definition.py
@@ -63,7 +63,6 @@
   the one to be used next for recycling.
 """
 
-
 from collections import defaultdict
 
 import numpy
diff --git a/src/cogent3/recalculation/scope.py b/src/cogent3/recalculation/scope.py
index 8e348131f..a388af93d 100644
--- a/src/cogent3/recalculation/scope.py
+++ b/src/cogent3/recalculation/scope.py
@@ -533,6 +533,7 @@ def assign_all(
         upper=None,
         const=None,
         independent=None,
+        warn=False,
     ):
         settings = []
         if const is None:
@@ -542,7 +543,7 @@ def assign_all(
             independent=independent, **(scope_spec or {})
         ):
             if value is None:
-                s_value = self.get_mean_current_value(scope)
+                s_value = self.get_mean_current_value(scope, warn=warn)
             else:
                 s_value = self.unwrap_value(value)
 
@@ -555,7 +556,7 @@ def assign_all(
                     )
                 setting = Var((None, s_value, None))
             else:
-                (s_lower, s_upper) = self.get_current_bounds(scope)
+                s_lower, s_upper = self.get_current_bounds(scope)
                 if lower is not None:
                     s_lower = lower
                 if upper is not None:
@@ -565,16 +566,18 @@ def assign_all(
                     raise ValueError("Bounds: upper < lower")
                 elif (s_lower is not None) and s_value < s_lower:
                     s_value = s_lower
-                    warnings.warn(
-                        f"Value of {self.name} increased to keep within bounds",
-                        stacklevel=3,
-                    )
+                    if warn:
+                        warnings.warn(
+                            f"Value of {self.name} increased to keep within bounds",
+                            stacklevel=3,
+                        )
                 elif (s_upper is not None) and s_value > s_upper:
                     s_value = s_upper
-                    warnings.warn(
-                        f"Value of {self.name} decreased to keep within bounds",
-                        stacklevel=3,
-                    )
+                    if warn:
+                        warnings.warn(
+                            f"Value of {self.name} decreased to keep within bounds",
+                            stacklevel=3,
+                        )
                 setting = Var((s_lower, s_value, s_upper))
             self.check_setting_is_valid(setting)
             settings.append((scope, setting))
@@ -584,19 +587,20 @@ def assign_all(
                 assert scope_t in self.assignments, scope_t
                 self.assignments[scope_t] = setting
 
-    def get_mean_current_value(self, scope):
+    def get_mean_current_value(self, scope, warn=False):
         values = [self.assignments[s].get_default_value() for s in scope]
         if len(values) == 1:
             s_value = values[0]
         else:
             s_value = sum(values) / len(values)
-            for value in values:
-                if not numpy.isclose(value, s_value).all():
-                    warnings.warn(
-                        f"Used mean of {len(values)} {self.name} values",
-                        stacklevel=4,
-                    )
-                    break
+            if warn:
+                for value in values:
+                    if not numpy.isclose(value, s_value).all():
+                        warnings.warn(
+                            f"Used mean of {len(values)} {self.name} values",
+                            stacklevel=4,
+                        )
+                        break
         return s_value
 
     def get_current_bounds(self, scope):
diff --git a/src/cogent3/util/deserialise.py b/src/cogent3/util/deserialise.py
index 22577bf70..f931c4f7b 100644
--- a/src/cogent3/util/deserialise.py
+++ b/src/cogent3/util/deserialise.py
@@ -5,7 +5,6 @@
 
 import cogent3
 
-from cogent3.core.genetic_code import get_code
 from cogent3.util.io import open_, path_exists
 
 
diff --git a/src/cogent3/util/dict_array.py b/src/cogent3/util/dict_array.py
index 6cb8f36da..4d1d47d52 100644
--- a/src/cogent3/util/dict_array.py
+++ b/src/cogent3/util/dict_array.py
@@ -19,6 +19,7 @@
     >>> b['a'].keys()
     ['A', 'B', 'C']
 """
+
 import json
 
 from collections import defaultdict
diff --git a/src/cogent3/util/parallel.py b/src/cogent3/util/parallel.py
index a1251a094..cf81811ac 100644
--- a/src/cogent3/util/parallel.py
+++ b/src/cogent3/util/parallel.py
@@ -216,9 +216,8 @@ def _as_completed_mpi(f, s, max_workers, if_serial, chunksize=None):
 
 def _as_completed_mproc(f, s, max_workers):
     """multiprocess version of as_completed"""
-    if not max_workers:
+    if not max_workers or max_workers > multiprocessing.cpu_count():
         max_workers = multiprocessing.cpu_count() - 1
-    assert max_workers < multiprocessing.cpu_count()
 
     f = PicklableAndCallable(f)
 
diff --git a/src/cogent3/util/recode_alignment.py b/src/cogent3/util/recode_alignment.py
index f02ed5f62..3ebbee095 100755
--- a/src/cogent3/util/recode_alignment.py
+++ b/src/cogent3/util/recode_alignment.py
@@ -34,7 +34,6 @@
 
 """
 
-
 from numpy import array, take, zeros
 
 from cogent3.core.alignment import ArrayAlignment
diff --git a/src/cogent3/util/table.py b/src/cogent3/util/table.py
index efbcbca13..24f39e03a 100644
--- a/src/cogent3/util/table.py
+++ b/src/cogent3/util/table.py
@@ -1089,9 +1089,11 @@ def get_row_indices(self, callback, columns, negate=False):
         match = not negate
         return numpy.array(
             [
-                True
-                if _callback(callback, row=row, num_columns=num_columns) == match
-                else False
+                (
+                    True
+                    if _callback(callback, row=row, num_columns=num_columns) == match
+                    else False
+                )
                 for row in data
             ]
         )
@@ -1941,12 +1943,6 @@ def to_list(self, columns=None):
 
         return result
 
-    @c3warn.deprecated_callable(version="2024.1", reason="PEP8", new="to_list")
-    def tolist(self, **kwargs):  # pragma: no cover
-        """deprecated, use to_list"""
-
-        return self.to_list(**kwargs)
-
     @extend_docstring_from(DictArray.to_dict)
     def to_dict(self, flatten=False):
         index = self.columns[self.index_name] if self.index_name else self.shape[0]
@@ -1987,14 +1983,6 @@ def to_pandas(self, categories=None):
 
         return df
 
-    @c3warn.deprecated_callable(
-        version="2024.3", reason="to enable usage by plotly", new="to_pandas()"
-    )
-    def to_dataframe(self, **kwargs):  # pragma: no cover
-        """deprecated, use to_pandas()"""
-
-        return self.to_pandas(**kwargs)
-
     def to_plotly(self, width=500, font_size=12, layout=None, **kwargs):
         """returns a Plotly Table"""
         from cogent3.draw.drawable import Drawable
diff --git a/src/cogent3/util/warning.py b/src/cogent3/util/warning.py
index 3cfe52f49..f4859b831 100644
--- a/src/cogent3/util/warning.py
+++ b/src/cogent3/util/warning.py
@@ -67,6 +67,7 @@ def deprecated_args(
     reason: str,
     old_new: List[Tuple[str, str]] = None,
     discontinued: List[str] = None,
+    stack_level=2,
 ) -> Callable[..., Any]:
     """
     A decorator that marks specific arguments of a function as deprecated.
@@ -130,11 +131,24 @@ def wrapper(*args: Any, **kwargs: Any) -> Callable[..., Any]:
                 for old, new in old_new:
                     if old in kwargs:
                         kwargs[new] = kwargs.pop(old)
-                        deprecated("argument", old, new, version, reason)
+                        deprecated(
+                            "argument",
+                            old,
+                            new,
+                            version,
+                            reason,
+                            stack_level=stack_level,
+                        )
             if discontinued:
                 for dropped in discontinued:
                     if dropped in kwargs:
-                        _discontinued("argument", dropped, version, reason)
+                        _discontinued(
+                            "argument",
+                            dropped,
+                            version,
+                            reason,
+                            stack_level=stack_level,
+                        )
 
             return func(*args, **kwargs)
 
@@ -189,6 +203,11 @@ def decorator(func: Callable) -> Callable:
         sig = set(inspect.signature(func).parameters)
         _type = "method" if sig & {"self", "cls", "klass"} else "function"
         old = func.__name__
+        if is_discontinued and old == "__init__":
+            # we're really deprecating a class, so get that name
+            old = func.__qualname__.split(".")[-2]
+            _type = "class"
+
         params = dict(
             _type=_type,
             old=old,


Test patch:
diff --git a/.github/workflows/testing_develop.yml b/.github/workflows/testing_develop.yml
index 6b2a66da1..4445bfdeb 100644
--- a/.github/workflows/testing_develop.yml
+++ b/.github/workflows/testing_develop.yml
@@ -10,21 +10,21 @@ on:
 
 jobs:
   tests:
-    name: "Python ${{ matrix.python-version }}"
+    name: "Python ${{ matrix.python-version }} (${{ matrix.os }})"
     runs-on: ${{ matrix.os }}
 
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.9", "3.10", "3.11"]
+        python-version: ["3.9", "3.10", "3.11", "3.12"]
 
     steps:
-      - uses: "actions/checkout@v2"
+      - uses: "actions/checkout@v4"
         with:
           fetch-depth: 0
 
       # Setup env
-      - uses: "actions/setup-python@v3"
+      - uses: "actions/setup-python@v5"
         with:
             python-version: "${{ matrix.python-version }}"
 
@@ -35,26 +35,23 @@ jobs:
           pip install --upgrade nox
 
       - name: "Run nox for ${{ matrix.python-version }}"
-        run: "nox -s test-${{ matrix.python-version }}"
+        run: "nox -s test-${{ matrix.python-version }} -- --cov-report lcov:lcov-${{matrix.os}}-${{matrix.python-version}}.lcov --cov-report term --cov-append --cov cogent3"
 
       - name: Coveralls Parallel
-        uses: coverallsapp/github-action@master
+        uses: coverallsapp/github-action@v2
         with:
           parallel: true
           github-token: ${{ secrets.github_token }}
-          flag-name: run-${{ matrix.test_number }}
-          path-to-lcov: "tests/lcov-${{ matrix.python-version }}.info"
+          flag-name: run-${{matrix.python-version}}-${{matrix.os}}
+          file: "tests/lcov-${{matrix.os}}-${{matrix.python-version}}.lcov"
 
   finish:
+    name: "Finish Coveralls"
     needs: tests
-    runs-on: ${{ matrix.os }}
-    strategy:
-      matrix:
-        os: [macos-latest, ubuntu-latest, windows-latest]
-        python-version: ["3.9", "3.10", "3.11"]
+    runs-on: ubuntu-latest
     steps:
     - name: Coveralls Finished
-      uses: coverallsapp/github-action@master
+      uses: coverallsapp/github-action@v2
       with:
         github-token: ${{ secrets.github_token }}
-        parallel-finished: true
+        parallel-finished: true
\ No newline at end of file
diff --git a/doc/doctest_rsts.py b/doc/doctest_rsts.py
index 817eacb69..e510e901c 100755
--- a/doc/doctest_rsts.py
+++ b/doc/doctest_rsts.py
@@ -83,9 +83,9 @@ def test_file(test: pathlib.Path | str, exit_on_first: bool = True) -> bool:
     return True
 
 
-def execute_rsts(file_paths, exit_on_first, verbose):
+def execute_rsts(file_paths, exit_on_first, parallel, verbose):
     runfile = test_file(exit_on_first=exit_on_first)
-    for result in runfile.as_completed(file_paths, parallel=True):
+    for result in runfile.as_completed(file_paths, parallel=parallel):
         if result is False and exit_on_first:
             exit(1)
 
@@ -112,8 +112,9 @@ def execute_rsts(file_paths, exit_on_first, verbose):
 @click.option(
     "-s", "--suffix", type=click.Choice(["rst", "ipynb"]), help="suffix of docs to test"
 )
+@click.option("-p", "--parallel", is_flag=True, help="run tests in parallel")
 @click.option("-v", "--verbose", is_flag=True, help="verbose output")
-def main(file_paths, just, exclude, exit_on_first, suffix, verbose):
+def main(file_paths, just, exclude, exit_on_first, suffix, parallel, verbose):
     """runs doctests for the indicated files"""
     cwd = os.getcwd()
     if "*" in file_paths:  # trim to just parent directory
@@ -151,7 +152,7 @@ def main(file_paths, just, exclude, exit_on_first, suffix, verbose):
         print(f"File paths, after filtering: {str(file_paths)}")
 
     if suffix == "rst":
-        execute_rsts(file_paths, exit_on_first, verbose)
+        execute_rsts(file_paths, exit_on_first, parallel, verbose)
     else:
         execute_ipynb(file_paths, exit_on_first, verbose)
 
diff --git a/src/cogent3/maths/stats/test.py b/src/cogent3/maths/stats/test.py
index 1c010ef5b..8da6102d6 100644
--- a/src/cogent3/maths/stats/test.py
+++ b/src/cogent3/maths/stats/test.py
@@ -37,7 +37,6 @@
 from cogent3.maths.stats.special import (
     MACHEP,
     Gamma,
-    lgam,
     log_one_minus,
     one_minus_exp,
 )
diff --git a/tests/test_align/test_align.py b/tests/test_align/test_align.py
index 74e633a7c..d6bd0b8ef 100644
--- a/tests/test_align/test_align.py
+++ b/tests/test_align/test_align.py
@@ -1,5 +1,4 @@
 import itertools
-import pathlib
 import unittest
 
 import numpy
diff --git a/tests/test_app/test_align.py b/tests/test_app/test_align.py
index 9e53d9b4e..288a84230 100644
--- a/tests/test_app/test_align.py
+++ b/tests/test_app/test_align.py
@@ -10,6 +10,7 @@
     DNA,
     get_app,
     get_moltype,
+    load_aligned_seqs,
     make_aligned_seqs,
     make_tree,
     make_unaligned_seqs,
@@ -148,7 +149,7 @@ def test_aln_to_ref_known(self):
         )
         expect = orig.to_dict()
         aligner = align_app.align_to_ref(ref_seq="Ref")
-        aln = aligner(orig.degap())
+        aln = aligner.main(orig.degap())
         self.assertEqual(aln.to_dict(), expect)
 
     def test_gap_union(self):
@@ -726,3 +727,31 @@ def test_smith_waterman_raises(seqs):
     coll = make_unaligned_seqs(data=[seqs.get_seq("Human")], moltype="dna")
     aln = aligner(coll)
     assert isinstance(aln, NotCompleted)
+
+
+def test_aln_two():
+    """correctly recapitulates known case"""
+    orig = make_aligned_seqs(
+        {
+            "Ref": "CAGGAGAACAGAAACCCATTACTCACT",
+            "Qu7": "CAGGA--ACAGA--CCCGTTA---ACT",
+        },
+        moltype="dna",
+    )
+    expect = orig.to_dict()
+    aligner = align_app.align_to_ref(ref_seq="Ref")
+    seqs = orig.degap()
+    aln = aligner.main(seqs)
+    assert aln.to_dict() == expect
+
+
+def test_codon_incomplete(DATA_DIR):
+    names = ["FlyingFox", "DogFaced", "FreeTaile"]
+    aln = load_aligned_seqs(DATA_DIR / "brca1.fasta", moltype="dna")
+    seqs = aln.take_seqs(names)[2700:3000].degap()
+    aligner = align_app.progressive_align("codon")
+    aln = aligner(seqs)
+    assert aln  # will fail if aln is a NotCompleted instance
+    # now make sure the resulting ungapped sequences are modulo 3
+    seqs = aln.degap().to_dict().values()
+    assert {len(s) % 3 for s in seqs} == {0}
diff --git a/tests/test_app/test_composable.py b/tests/test_app/test_composable.py
index eabec7301..4066bbb75 100644
--- a/tests/test_app/test_composable.py
+++ b/tests/test_app/test_composable.py
@@ -22,12 +22,12 @@
     NON_COMPOSABLE,
     WRITER,
     NotCompleted,
-    __app_registry,
     _add,
     _get_raw_hints,
     define_app,
     get_object_provenance,
-    is_composable,
+    is_app,
+    is_app_composable,
 )
 from cogent3.app.data_store import (
     APPEND,
@@ -185,8 +185,6 @@ def main(self, val: int) -> int:
     expect = "app_dummyclass_1(a=1) + " "app_dummyclass_2(b=2)"
     got = str(comb)
     assert got == expect
-    __app_registry.pop(get_object_provenance(app_dummyclass_1), None)
-    __app_registry.pop(get_object_provenance(app_dummyclass_2), None)
 
 
 def test_composables_once():
@@ -223,10 +221,6 @@ def main(self, val: int) -> int:
     with pytest.raises(ValueError):
         two + three  # three already has an input
 
-    __app_registry.pop(get_object_provenance(app_dummyclass_1), None)
-    __app_registry.pop(get_object_provenance(app_dummyclass_2), None)
-    __app_registry.pop(get_object_provenance(app_dummyclass_3), None)
-
 
 def test_composable_to_self():
     """this should raise a ValueError"""
@@ -243,8 +237,6 @@ def main(self, val: int) -> int:
     with pytest.raises(ValueError):
         _ = app1 + app1
 
-    __app_registry.pop(get_object_provenance(app_dummyclass_1), None)
-
 
 def test_disconnect():
     """disconnect breaks all connections and allows parts to be reused"""
@@ -283,10 +275,6 @@ def main(self, val: int) -> int:
     # should be able to compose a new one now
     aseqfunc1 + aseqfunc3
 
-    __app_registry.pop(get_object_provenance(app_dummyclass_1), None)
-    __app_registry.pop(get_object_provenance(app_dummyclass_2), None)
-    __app_registry.pop(get_object_provenance(app_dummyclass_3), None)
-
 
 def test_as_completed(DATA_DIR):
     """correctly applies iteratively"""
@@ -600,10 +588,6 @@ def bar(val: AlignedSeqsType, num=3) -> PairwiseDistanceType:
     return val.distance_matrix(calc="hamming", show_progress=False)
 
 
-for _app_ in (foo, bar, foo_without_arg_kwargs):
-    __app_registry.pop(get_object_provenance(_app_), None)
-
-
 def test_user_function():
     """composable functions should be user definable"""
 
@@ -651,48 +635,31 @@ def test_user_function_str():
     assert got == "bar(num=3)"
 
 
-def test_app_registry():
-    """correctly registers apps"""
-
-    @define_app
-    class app_test_registry1:
-        def main(self, data: int) -> int:
-            return data
-
-    assert __app_registry["test_composable.app_test_registry1"]
-
-    # delete it to not include in app available apps
-    __app_registry.pop(get_object_provenance(app_test_registry1), None)
-
-
-def test_app_is_composable():
-    """check is_composable for composable apps"""
+def test_decorated_app_is_app():
+    """check is_app for define_app decorated apps"""
 
     @define_app
-    class app_test_iscomposable1:
+    class app_test_isapp1:
         def main(self, data: int) -> int:
             return data
 
-    assert is_composable(app_test_iscomposable1)
-
-    # delete it to not include in app available apps
-    __app_registry.pop(get_object_provenance(app_test_iscomposable1), None)
+    assert is_app(app_test_isapp1)
 
 
-def test_app_is_not_composable():
-    """check is_composable for non-composable apps"""
+def test_undecorated_app_is_not_an_app():
+    """check is_app for non-decorated apps"""
 
     class app_not_composable1:
         def main(self, data: int) -> int:
             return data
 
-    assert not is_composable(app_not_composable1)
+    assert not is_app(app_not_composable1)
 
 
 def test_concat_not_composable():
     from cogent3.app.sample import concat
 
-    assert not is_composable(concat)
+    assert not is_app_composable(concat)
 
 
 def test_composed_func_pickleable():
@@ -721,8 +688,6 @@ def main(self, val: int) -> int:
     instance = pos_var_pos1(2, 3, 4, 5, 6)
     assert instance._init_vals == {"a": 2, "b": 3, "args": (4, 5, 6)}
 
-    __app_registry.pop(get_object_provenance(pos_var_pos1), None)
-
 
 def test_composable_minimum_parameters():
     """correctly associate argument vals with their names when have variable
@@ -796,8 +761,6 @@ def main(self, val: int) -> int:
     instance = pos_var_pos_kw2(2, 3, 4, 5, 6, c=True)
     assert instance._init_vals == {"a": 2, "args": (3, 4, 5, 6), "c": True}
 
-    __app_registry.pop(get_object_provenance(pos_var_pos_kw2), None)
-
 
 def test_app_decoration_fails_with_slots():
     with pytest.raises(NotImplementedError):
@@ -825,8 +788,6 @@ def main(self, val: int) -> int:
     with pytest.raises(TypeError):
         define_app(app_decorated_repeated1)
 
-    __app_registry.pop(get_object_provenance(app_decorated_repeated1), None)
-
 
 def test_recursive_decoration():
     @define_app
@@ -841,8 +802,6 @@ def main(self, val: int) -> int:
     with pytest.raises(TypeError):
         app_docorated_recursive1().main(1)
 
-    __app_registry.pop(get_object_provenance(app_docorated_recursive1), None)
-
 
 def test_inheritance_from_decorated_class():
     @define_app
@@ -863,8 +822,6 @@ def __init__(self, a):
             def main(self, val: int) -> int:
                 return val
 
-    __app_registry.pop(get_object_provenance(app_decorated_first1), None)
-
 
 # have to define this at module level for pickling to work
 @define_app
@@ -879,8 +836,6 @@ def test_decorate_app_function():
     assert sqd(3) == 9
     assert inspect.isclass(func2app)
 
-    __app_registry.pop(get_object_provenance(func2app), None)
-
 
 def test_roundtrip_decorated_function():
     """decorated function can be pickled/unpickled"""
@@ -889,8 +844,6 @@ def test_roundtrip_decorated_function():
     u = pickle.loads(pickle.dumps(sqd))
     assert u(4) == 16
 
-    __app_registry.pop(get_object_provenance(func2app), None)
-
 
 def test_decorated_func_optional():
     @define_app(app_type=NON_COMPOSABLE)
@@ -900,8 +853,6 @@ def power(val: int, pow: int = 1) -> int:
     sqd = power(2)
     assert sqd(3) == 9
 
-    __app_registry.pop(get_object_provenance(power), None)
-
 
 def test_decorated_func_repr():
     def kw(val: int = 1) -> int:
@@ -935,8 +886,6 @@ def pos_kw(val: int, pow: int = 1) -> int:
 
         assert repr(instance) == expect, name
 
-        __app_registry.pop(get_object_provenance(instance), None)
-
 
 def test_decorated_func_just_args():
     @define_app(app_type=NON_COMPOSABLE)
@@ -946,8 +895,6 @@ def power(val: int, pow: int) -> int:
     sqd = power()
     assert sqd(3, 3) == 27
 
-    __app_registry.pop(get_object_provenance(power), None)
-
 
 @pytest.mark.parametrize(
     "meth",
@@ -1022,9 +969,6 @@ def main(self, val: int) -> int:
     with pytest.raises(TypeError):
         app1 + app2
 
-    __app_registry.pop(get_object_provenance(app_non_composable1), None)
-    __app_registry.pop(get_object_provenance(app_non_composable2), None)
-
 
 _types_null = (list, []), (tuple, ())
 
@@ -1041,8 +985,6 @@ def null_in(val: in_type, pow: int) -> int:
     got = app(input)
     assert isinstance(got, NotCompleted)
 
-    __app_registry.pop(get_object_provenance(null_in), None)
-
 
 @pytest.mark.parametrize("ret_type", (0, array([]), [], {}))
 def test_handles_null_output(ret_type):
@@ -1057,8 +999,6 @@ def null_out(val: ndarray, pow: int) -> int:
     got = app(d)
     assert isinstance(got, type(ret_type))
 
-    __app_registry.pop(get_object_provenance(null_out), None)
-
 
 def test_handles_None():
     """apps correctly handle null output"""
@@ -1081,8 +1021,6 @@ def take_int(val: int) -> int:
     got = app(d)
     assert isinstance(got, NotCompleted)
 
-    __app_registry.pop(get_object_provenance(none_out), None)
-
 
 def test_validate_data_type_not_completed_pass_through():
     # returns the instance of a NotCompleted created by an input
@@ -1098,9 +1036,6 @@ def take_int2(val: int) -> int:
     got = app(2)
     assert got.origin == "take_int1"
 
-    __app_registry.pop(get_object_provenance(take_int1), None)
-    __app_registry.pop(get_object_provenance(take_int2), None)
-
 
 @pytest.mark.parametrize("first,ret", ((Tuple[Set[str]], int), (int, Tuple[Set[str]])))
 def test_complex_type(first, ret):
@@ -1132,8 +1067,6 @@ class x:
         def main(self, data: hint) -> int:
             return int
 
-    __app_registry.pop(get_object_provenance(x), None)
-
 
 def test_apply_to_only_appends(half_dstore1, half_dstore2):
     half_dstore1 = open_data_store(
@@ -1176,8 +1109,6 @@ def takes_not_completed(val: c3types.SerialisableType) -> dict:
     assert isinstance(got, dict)
     assert got == nc.to_rich_dict()
 
-    __app_registry.pop(get_object_provenance(takes_not_completed), None)
-
 
 def test_copies_doc_from_func():
     @define_app
@@ -1198,6 +1129,3 @@ def delme2(val: c3types.SerialisableType) -> dict:
 
     assert delme2.__doc__ == "my docstring"
     assert delme2.__init__.__doc__.split() == ["Notes", "-----", "body"]
-
-    __app_registry.pop(get_object_provenance(delme), None)
-    __app_registry.pop(get_object_provenance(delme2), None)
diff --git a/tests/test_app/test_evo.py b/tests/test_app/test_evo.py
index 42a059989..e502188f6 100644
--- a/tests/test_app/test_evo.py
+++ b/tests/test_app/test_evo.py
@@ -870,6 +870,7 @@ def test_model_opt_args():
     assert model._opt_args == {**opt_args, **{"show_progress": False}}
 
 
+@pytest.mark.internet
 def test_get_app_tree_is_url():
     """A check that the model app can use a url for the tree"""
     tree_url = "https://raw.githubusercontent.com/cogent3/cogent3/develop/tests/data/brca1_5.tree"
diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
index 0f4db0228..58e788295 100644
--- a/tests/test_app/test_init.py
+++ b/tests/test_app/test_init.py
@@ -1,4 +1,5 @@
 """testing the default import"""
+
 import os
 
 from pathlib import Path
@@ -8,14 +9,7 @@
 import pytest
 
 from cogent3 import app_help, available_apps, get_app, open_data_store
-from cogent3.app.composable import (
-    LOADER,
-    WRITER,
-    __app_registry,
-    define_app,
-    is_composable,
-)
-from cogent3.util.misc import get_object_provenance
+from cogent3.app.composable import LOADER, WRITER, is_app
 from cogent3.util.table import Table
 
 
@@ -76,7 +70,7 @@ def test_composable_pairwise_applications(self):
         with TemporaryDirectory(dir=".") as dirname:
             applications = _get_all_composables(os.path.join(dirname, "delme"))
             for app in applications:
-                self.assertTrue(is_composable(app), msg=app)
+                self.assertTrue(is_app(app), msg=app)
 
             composable_application_tuples = [
                 (app1, app2)
@@ -103,7 +97,7 @@ def test_incompatible_pairwise_applications(self):
         with TemporaryDirectory(dir=".") as dirname:
             applications = _get_all_composables(os.path.join(dirname, "delme"))
             for app in applications:
-                self.assertTrue(is_composable(app))
+                self.assertTrue(is_app(app))
 
             incompatible_application_tuples = [
                 (app1, app2)
@@ -126,21 +120,8 @@ def test_incompatible_pairwise_applications(self):
                     app_a + app_b
 
 
-def test_available_apps_local():
-    """available_apps robust to local scope apps"""
-
-    @define_app
-    def dummy(val: int) -> int:
-        return val
-
-    apps = available_apps()
-    assert isinstance(apps, Table)
-    __app_registry.pop(get_object_provenance(dummy), None)
-
-
 @pytest.mark.parametrize("name", ("sample.min_length", "min_length"))
 def test_get_app(name):
-    __app_registry.pop(get_object_provenance(min_length), None)
     app = get_app(name, 500)
     assert app.__class__.__name__.endswith(name.split(".")[-1])
 
@@ -151,49 +132,11 @@ def test_get_app_kwargs():
     _ = get_app("model", "F81", name="F81-model")
 
 
-@define_app
-def min_length(val: int) -> int:
-    return val
-
-
-def test_get_app_fail():
-    __app_registry[get_object_provenance(min_length)] = True
-
-    with pytest.raises(NameError):
-        _ = get_app("min_length", 500)
-
-    __app_registry.pop(get_object_provenance(min_length), None)
-
-
 def test_app_help(capsys):
-    app_help("compress")
+    app_help("concat")
     got = capsys.readouterr().out
     assert "Options" in got
-    assert got.count("bytes") >= 2  # both input and output types are bytes
-
-
-@define_app
-class blah:
-    def __init__(self):
-        self.constant = 2
-
-    def main(self, val: int) -> int:
-        return val + self.constant
-
-
-@pytest.mark.parametrize(
-    "app_doc,init_doc", ((None, None), ("text", None), (None, "text"), ("text", "text"))
-)
-def test_app_help_no_docs(capsys, app_doc, init_doc):
-    blah.__doc__ = app_doc
-    blah.__init__.__doc__ = init_doc
-    app_help("blah")
-    got = capsys.readouterr().out
-    if app_doc:
-        assert "Overview" in got
-
-    if init_doc:
-        assert "Options" in got
+    assert got.count("SerialisableType") == 1  # output type
 
 
 @pytest.mark.parametrize(
@@ -202,6 +145,9 @@ def test_app_help_no_docs(capsys, app_doc, init_doc):
 def test_app_help_signature(capsys, app_name):
     from cogent3.app import _get_app_matching_name, _make_signature
 
+    with pytest.raises(ValueError, match="app cannot be None"):
+        _make_signature(None)
+
     got = _make_signature(_get_app_matching_name(app_name))
     # app name is in quotes
     assert f'"{app_name}"' in got
diff --git a/tests/test_app/test_io.py b/tests/test_app/test_io.py
index 547c66903..8e55b1994 100644
--- a/tests/test_app/test_io.py
+++ b/tests/test_app/test_io.py
@@ -608,19 +608,19 @@ def test_default_serialiser_deserialiser(data):
 
 
 def test_to_json():
-    to_j = get_app("to_json")
+    to_j = io_app.to_json()
     data = {"a": [0, 1]}
     assert to_j(data) == json.dumps(data)
 
 
 def test_from_json():
-    from_j = get_app("from_json")
+    from_j = io_app.from_json()
     assert from_j('{"a": [0, 1]}') == {"a": [0, 1]}
 
 
 def test_to_from_json():
-    to_j = get_app("to_json")
-    from_j = get_app("from_json")
+    to_j = io_app.to_json()
+    from_j = io_app.from_json()
     app = to_j + from_j
     data = {"a": [0, 1]}
     assert app(data) == data
@@ -628,7 +628,7 @@ def test_to_from_json():
 
 
 def test_to_json_combines():
-    app = get_app("to_primitive") + get_app("to_json")
+    app = io_app.to_primitive() + io_app.to_json()
     assert app(DNA) == DNA.to_json()
 
 
diff --git a/tests/test_app/test_plugins.py b/tests/test_app/test_plugins.py
new file mode 100644
index 000000000..3b5ae1348
--- /dev/null
+++ b/tests/test_app/test_plugins.py
@@ -0,0 +1,280 @@
+import random
+import string
+
+from importlib.metadata import EntryPoint
+from unittest.mock import patch
+
+import pytest
+
+from stevedore import extension
+from stevedore.extension import ExtensionManager
+
+import cogent3
+
+from cogent3.app import (
+    _make_apphelp_docstring,
+    app_help,
+    available_apps,
+    get_app,
+)
+from cogent3.app.composable import define_app
+from cogent3.util.table import Table
+
+
+@pytest.fixture
+def extension_manager_factory():
+    """Fixture to create mocked ExtensionManager instances with given extensions."""
+
+    def _factory(extensions):
+        with patch("stevedore.ExtensionManager") as mock_manager_constructor:
+            mock_manager = ExtensionManager.make_test_instance(
+                extensions=extensions, namespace="TESTING"
+            )
+            mock_manager_constructor.return_value = mock_manager
+            return mock_manager
+
+    return _factory
+
+
+@pytest.fixture
+def mock_extension_manager(extension_manager_factory, monkeypatch):
+    """Fixture to mock the ExtensionManager with the given extensions."""
+
+    def _mock_extension_manager(extensions):
+        # Create a mocked ExtensionManager with the specified mock extensions
+        mocked_manager = extension_manager_factory(extensions)
+        # Patch the __apps variable in cogent3.apps module to use the mocked_manager
+        monkeypatch.setattr(cogent3.app, "__apps", mocked_manager)
+        return mocked_manager
+
+    return _mock_extension_manager
+
+
+def create_extension(
+    plugin: object, name: str = None, module_name: str = "module1"
+) -> extension.Extension:
+    if name is None:
+        name = plugin.__name__
+    return extension.Extension(
+        name=name,
+        entry_point=EntryPoint(
+            name=name, value=f"{module_name}:{name}", group="TESTING"
+        ),
+        plugin=plugin,
+        obj=None,
+    )
+
+
+def test_install_app_class(mock_extension_manager):
+    @define_app
+    class uppercase:
+        """Test app that converts a string to uppercase"""
+
+        def main(self, data: str) -> str:
+            return data.upper()
+
+    mock_extension_manager([create_extension(uppercase)])
+
+    appercase = get_app("uppercase")
+    assert appercase("hello") == "HELLO"
+    assert appercase.__doc__ in _make_apphelp_docstring(appercase.__class__)
+
+
+def test_install_app_function(mock_extension_manager):
+    @define_app
+    def uppercase(data: str) -> str:
+        """Test function that converts a string to uppercase"""
+        return data.upper()
+
+    mock_extension_manager([create_extension(uppercase)])
+
+    appercase = get_app("uppercase")
+    assert appercase("hello") == "HELLO"
+    assert appercase.__doc__ in _make_apphelp_docstring(appercase.__class__)
+
+
+@pytest.mark.parametrize("app_doc", [None, "text"])
+@pytest.mark.parametrize("init_doc", [None, "text"])
+def test_app_docs(mock_extension_manager, app_doc, init_doc):
+    @define_app
+    class documented_app:
+        """This is a test app that has a __init__, and a docstring"""
+
+        def __init__(self):
+            self.constant = 2
+
+        def main(self, val: int) -> int:
+            return val + self.constant
+
+    mock_extension_manager([create_extension(documented_app)])
+
+    assert cogent3.app.get_app_manager().names() == ["documented_app"]
+    app = get_app("documented_app")
+    app.__class__.__doc__ = app_doc
+    app.__class__.__init__.__doc__ = init_doc
+    app_help("documented_app")
+    got = _make_apphelp_docstring(app.__class__)
+    assert "Options" in got
+
+
+def test_namespace_collision(mock_extension_manager):
+    @define_app
+    class app1:
+        def main(self, data: str) -> str:
+            return data.upper()
+
+    @define_app
+    class app2:
+        def main(self, data: str) -> str:
+            return data.lower()
+
+    # create two apps with the same name and different modules
+    mock_extension_manager(
+        [
+            create_extension(app1, module_name="module1"),
+            create_extension(app2, name="app1", module_name="module2"),
+        ]
+    )
+
+    assert cogent3.app.get_app_manager().names() == ["app1", "app1"]
+
+    with pytest.raises(NameError):
+        _ = get_app(
+            "app1"
+        )  # request app by name only, when there are multiple apps with the same name
+
+    app_by_module_name_1 = get_app("module1.app1")  # request app by name and module
+    app_by_module_name_2 = get_app("module2.app1")
+    assert app_by_module_name_1("Hello") == "HELLO"
+    assert app_by_module_name_2("Hello") == "hello"
+
+    composition = app_by_module_name_1 + app_by_module_name_2
+    assert composition("Hello") == "hello"
+
+
+def test_available_apps_local(mock_extension_manager):
+    """available_apps robust to local scope apps"""
+
+    @define_app
+    def dummy(val: int) -> int:
+        return val
+
+    mock_extension_manager([create_extension(dummy)])
+    apps = available_apps()
+    assert isinstance(apps, Table)
+    apps = apps.filtered(lambda x: dummy.__name__ == x, columns="name")
+    assert apps.shape[0] == 1
+
+
+def test_stevedore_finds_non_apps(mock_extension_manager):
+    """available_apps should return plugins that fail is_app() but emit a warning"""
+
+    def not_an_app(val: int) -> int:
+        return val
+
+    with pytest.warns(UserWarning, match=r".* is not a valid cogent3 app, skipping"):
+        mock_extension_manager([create_extension(not_an_app)])
+        apps = available_apps()
+        assert isinstance(apps, Table)
+        apps = apps.filtered(lambda x: not_an_app.__name__ == x, columns="name")
+        assert apps.shape[0] == 1
+
+
+def test_unknown_app_name(mock_extension_manager):
+    """get_app should raise a ValueError if the app name is not found"""
+
+    mock_extension_manager([])
+    unknown_app_name = "".join(random.choices(string.ascii_lowercase, k=10))
+
+    with pytest.raises(
+        ValueError, match=f"App '{unknown_app_name}' not found. Please check for typos."
+    ):
+        _ = get_app(unknown_app_name)
+
+
+def test_unknown_module_name(mock_extension_manager):
+    """get_app should raise a ValueError if the app name is not found"""
+
+    @define_app
+    def dummy(val: int) -> int:
+        return val
+
+    mock_extension_manager([create_extension(dummy, module_name="module1")])
+
+    assert dummy.__name__ in cogent3.app.get_app_manager().names()
+    assert get_app(dummy.__name__)(5) == 5
+    with pytest.raises(ValueError, match=".* not found. Please check for typos."):
+        _ = get_app(".".join(["module_2", dummy.__name__]))
+
+
+def test_app_help_from_instance(mock_extension_manager):
+    """_make_apphelp_docstring(instance) should return help on the app class"""
+
+    @define_app
+    class DummyApp:
+        def __init__(self, a: int = 7919):
+            self.a = a
+
+        def main(self, data: str = "foo") -> str:
+            return data.upper()
+
+    mock_extension_manager([create_extension(DummyApp, module_name="module1")])
+
+    assert DummyApp.__name__ in cogent3.app.get_app_manager().names()
+    dummy_instance = DummyApp()
+    got = _make_apphelp_docstring(dummy_instance)
+
+    assert "Options" in got  # test help is rendered
+    assert "DummyApp_app" in got  # test the help is for the correct app
+    assert (
+        "7919" in got
+    )  # test signature rendering is accurate and detailed and includes default of the 1000th prime
+
+
+def test_app_with_app_as_default(mock_extension_manager):
+    """apps can be initialized with other apps as arguments"""
+
+    @define_app
+    class AddApp:
+        def __init__(self, seed: int):
+            self.seed = seed
+
+        def main(self, data: int) -> int:
+            return data + self.seed
+
+    @define_app
+    class AppWithDefault:
+        def __init__(self, app: AddApp = AddApp(37)):
+            self.app = app
+
+        def main(self, data: int) -> int:
+            return self.app.main(data)
+
+    mock_extension_manager([create_extension(AddApp), create_extension(AppWithDefault)])
+
+    assert AppWithDefault.__name__ in cogent3.app.get_app_manager().names()
+    assert "AddApp(seed=37)" in _make_apphelp_docstring(AppWithDefault)
+    app_with_default_addapp = get_app("AppWithDefault")
+    assert app_with_default_addapp(5) == 42
+    app_with_custom_addapp = get_app("AppWithDefault", app=AddApp(10))
+    assert app_with_custom_addapp(5) == 15
+
+
+def test_app_help_from_function(mock_extension_manager):
+    """_make_apphelp_docstring on a decorated function should return help"""
+
+    @define_app
+    def square(val: int) -> int:
+        """app that returns the square of the input value"""
+        return val * val
+
+    mock_extension_manager([create_extension(square, module_name="module1")])
+
+    assert square.__name__ in cogent3.app.get_app_manager().names()
+    got = _make_apphelp_docstring(square)
+
+    assert "Options" in got  # test help is rendered
+    assert "square_app" in got  # test the help is for the correct app
+    assert (
+        "the square of the input" in got
+    )  # test the docstring is included in the help
diff --git a/tests/test_app/test_sample.py b/tests/test_app/test_sample.py
index ffad3aadb..eb1ca31dc 100644
--- a/tests/test_app/test_sample.py
+++ b/tests/test_app/test_sample.py
@@ -1,5 +1,7 @@
 from unittest import TestCase
 
+import pytest
+
 from cogent3 import DNA, make_aligned_seqs, make_unaligned_seqs
 from cogent3.app import composable, sample
 from cogent3.app.composable import NotCompleted
@@ -61,6 +63,13 @@ def test_filter_degen(self):
         got = degen(aln)
         self.assertIsInstance(got, alignment.Alignment)
 
+        # motif length exludes tuples with a degenerate site
+        aln = make_aligned_seqs({"a": "ACGA-GACG", "b": "GATGATGYT"})
+        degen = sample.omit_degenerates(moltype="dna", motif_length=2)
+        got = degen(aln)
+        expect = make_aligned_seqs({"a": "ACGA", "b": "GATG"}, moltype="dna")
+        assert got == expect
+
     def test_omit_gapped(self):
         """omit_gap_pos correctly drops aligned columns"""
         # array alignment
@@ -442,10 +451,6 @@ def test_concat_validates_type(self):
         got = ccat(data[::-1])
         self.assertIsInstance(got, composable.NotCompleted)
 
-        # triggered by no data
-        got = ccat([])
-        self.assertIsInstance(got, composable.NotCompleted)
-
     def test_trim_stop_codons(self):
         """trims stop codons using the specified genetic code"""
         trimmer = sample.trim_stop_codons()  # defaults to standard code
@@ -543,3 +548,20 @@ def test_take_n_seqs(self):
         take = sample.take_n_seqs(2, random=True, seed=123)
         got = take(seqs1)
         self.assertNotIsInstance(got, NotCompleted)
+
+
+def test_concat_coerced_moltype():
+    # moltype of final result is the first one seen
+    concat = sample.concat()
+    aln1 = make_aligned_seqs({"s1": "AAA", "s2": "CAA", "s3": "AAA"}, moltype="dna")
+    aln2 = make_aligned_seqs({"s1": "GCG", "s2": "GGG", "s3": "GGT"})
+    result = concat([aln1, aln2])
+    assert result.moltype.label == "dna"
+
+
+@pytest.mark.parametrize("data", ([], [make_aligned_seqs({"s1": "", "s2": ""})]))
+def test_concat_empty(data):
+    # triggered by empty alignment
+    ccat = sample.concat()
+    got = ccat(data)
+    assert isinstance(got, NotCompleted)
diff --git a/tests/test_app/test_sqlite_data_store.py b/tests/test_app/test_sqlite_data_store.py
index bd5ced317..1c12e94de 100644
--- a/tests/test_app/test_sqlite_data_store.py
+++ b/tests/test_app/test_sqlite_data_store.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from pickle import dumps, loads
 
@@ -151,7 +151,7 @@ def test_db_init_log():
     dstore._init_log()
     rows = dstore.db.execute(f"Select * from {_LOG_TABLE}").fetchall()
     assert len(rows) == 1
-    assert rows[0]["date"].date() == datetime.today().date()
+    assert rows[0]["date"].date() == datetime.now(tz=timezone.utc).date()
 
 
 def test_open_sqlite_db_rw():
diff --git a/tests/test_app/test_translate.py b/tests/test_app/test_translate.py
index 6a8bc6df5..9f0ef3527 100644
--- a/tests/test_app/test_translate.py
+++ b/tests/test_app/test_translate.py
@@ -142,7 +142,3 @@ def test_get_fourfold_degenerate_sets(self):
                 get_code(i), alphabet=DNA.alphabet, as_indices=True
             )
             self.assertEqual(got, expect)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_app/test_typing.py b/tests/test_app/test_typing.py
index c07c89987..080cd4421 100644
--- a/tests/test_app/test_typing.py
+++ b/tests/test_app/test_typing.py
@@ -42,9 +42,14 @@ def test_get_constraint_names():
 
 def test_get_constraint_names_builtins():
     """handles built-in types"""
+    expected = {"str", "bytes"}
 
     got = get_constraint_names(Union[str, bytes])
-    assert got == {"str", "bytes"}
+    assert got == expected
+
+    if sys.version_info.minor > 9:
+        got = get_constraint_names(str | bytes)
+        assert got == expected
 
 
 def test_get_constraint_names_serilisable():
@@ -63,16 +68,27 @@ def test_get_constraint_names_identifiertype():
 
 def test_get_constraint_names_mixed_serilisable_identifiertype():
     """SerialisableType does not define any compatible types"""
+    expected = {"SerialisableType", "IdentifierType", "Alignment", "ArrayAlignment"}
 
     got = get_constraint_names(Union[SerialisableType, IdentifierType, AlignedSeqsType])
-    assert got == {"SerialisableType", "IdentifierType", "Alignment", "ArrayAlignment"}
+    assert got == expected
+
+    if sys.version_info.minor > 9:
+        got = get_constraint_names(SerialisableType | IdentifierType | AlignedSeqsType)
+        assert got == expected
 
 
 def test_hints_resolved_from_str():
     got = get_constraint_names("DnaSequence")
     assert got == {"DnaSequence"}
+
+    expected = {"SerialisableType", "DnaSequence"}
     got = get_constraint_names(Union[SerialisableType, "DnaSequence"])
-    assert got == {"SerialisableType", "DnaSequence"}
+    assert got == expected
+
+    if sys.version_info.minor > 9:
+        got = get_constraint_names(SerialisableType | "DnaSequence")
+        assert got == expected
 
 
 @pytest.mark.parametrize("container", (List, Tuple, Set))
@@ -94,8 +110,7 @@ def test_hints_from_container_type_obj(container):
 def test_hint_inherited_class():
     from collections.abc import MutableSequence
 
-    class dummy(MutableSequence):
-        ...
+    class dummy(MutableSequence): ...
 
     got = get_constraint_names(dummy)
     assert got == frozenset(["dummy"])
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 665a923c9..264cc79ea 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -11,7 +11,7 @@
 import numpy
 import pytest
 
-from numpy import array, log2, nan, transpose
+from numpy import array, log2, nan
 from numpy.testing import assert_allclose, assert_equal
 
 from cogent3 import (
@@ -238,24 +238,6 @@ def test_Items(self):
         self.assertEqual(list(self.ordered1.iter_selected()), ["A"] * 5 + ["B"] * 5)
         self.assertEqual(list(self.ordered2.iter_selected()), ["B"] * 5 + ["A"] * 5)
 
-    def test_iter_selected(self):
-        """SequenceCollection iter_selected() should iterate over items in correct order"""
-        # should work if one row
-        self.assertEqual(list(self.one_seq.iter_selected()), ["A"] * 5)
-        # should take order into account
-        self.assertEqual(list(self.ordered1.iter_selected()), ["A"] * 5 + ["B"] * 5)
-        self.assertEqual(list(self.ordered2.iter_selected()), ["B"] * 5 + ["A"] * 5)
-        # should allow row and/or col specification
-        r = self.ragged_padded
-        self.assertEqual(
-            list(r.iter_selected(seq_order=["c", "b"], pos_order=[5, 1, 3])),
-            list("-AA-A-"),
-        )
-        # should not interfere with superclass iteritems()
-        i = list(r.named_seqs.items())
-        i.sort()
-        self.assertEqual(i, [("a", "AAAAAA"), ("b", "AAA---"), ("c", "AAAA--")])
-
     def test_take_seqs(self):
         """SequenceCollection take_seqs should return new SequenceCollection with selected seqs."""
         a = self.ragged_padded.take_seqs(list("bc"))
@@ -431,8 +413,18 @@ def test_to_phylip(self):
 
     def test_to_fasta(self):
         """SequenceCollection should return correct FASTA string"""
-        aln = self.Class(["AAA", "CCC"])
-        self.assertEqual(aln.to_fasta(), ">seq_0\nAAA\n>seq_1\nCCC\n")
+        aln1 = self.Class(["AAA", "CCC"])
+        self.assertEqual(aln1.to_fasta(), ">seq_0\nAAA\n>seq_1\nCCC\n")
+        self.assertEqual(aln1.to_fasta(block_size=2), ">seq_0\nAA\nA\n>seq_1\nCC\nC\n")
+
+        aln2 = self.Class(["GCATGCAT", "TCAGACGT"])
+        self.assertEqual(aln2.to_fasta(), ">seq_0\nGCATGCAT\n>seq_1\nTCAGACGT\n")
+        self.assertEqual(
+            aln2.to_fasta(block_size=4), ">seq_0\nGCAT\nGCAT\n>seq_1\nTCAG\nACGT\n"
+        )
+        self.assertEqual(
+            aln2.to_fasta(block_size=3), ">seq_0\nGCA\nTGC\nAT\n>seq_1\nTCA\nGAC\nGT\n"
+        )
 
     def test_to_nexus(self):
         """SequenceCollection should return correct Nexus string format"""
@@ -457,13 +449,6 @@ def test_to_nexus(self):
         got = align_norm.to_nexus("protein")
         self.assertEqual(got, expect)
 
-    def test_to_json(self):
-        """roundtrip of to_json produces correct dict"""
-        aln = self.Class({"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"})
-        got = json.loads(aln.to_json())
-        expect = aln.to_rich_dict()
-        self.assertEqual(got, expect)
-
     def test_num_seqs(self):
         """SequenceCollection.num_seqs should count seqs."""
         aln = self.Class({"seq1": "ACGU", "seq2": "CGUA", "seq3": "CCGU"})
@@ -480,54 +465,6 @@ def test_add_info(self):
         align = align1 + align2
         self.assertEqual(align.info["key"], "foo")
 
-    def test_add_seqs(self):
-        """add_seqs should return an alignment with the new sequences appended or inserted"""
-        data = [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
-        data1 = [("name1", "AAA"), ("name2", "AAA")]
-        data2 = [("name3", "AAA"), ("name4", "AAA")]
-        data3 = [("name5", "BBB"), ("name6", "CCC")]
-        aln = self.Class(data)
-        aln3 = self.Class(data3)
-
-        out_aln = aln.add_seqs(aln3)
-        # test append at the end
-        self.assertEqual(str(out_aln), str(self.Class(data + data3)))
-
-        out_aln = aln.add_seqs(aln3, before_name="name3")
-        self.assertEqual(
-            str(out_aln), str(self.Class(data1 + data3 + data2))
-        )  # test insert before
-
-        out_aln = aln.add_seqs(aln3, after_name="name2")
-        self.assertEqual(
-            str(out_aln), str(self.Class(data1 + data3 + data2))
-        )  # test insert after
-
-        out_aln = aln.add_seqs(aln3, before_name="name1")
-        # test if insert before first seq works
-        self.assertEqual(str(out_aln), str(self.Class(data3 + data)))
-
-        out_aln = aln.add_seqs(aln3, after_name="name4")
-        # test if insert after last seq works
-        self.assertEqual(str(out_aln), str(self.Class(data + data3)))
-
-        self.assertRaises(
-            ValueError, aln.add_seqs, aln3, before_name="name5"
-        )  # wrong after/before name
-        self.assertRaises(
-            ValueError, aln.add_seqs, aln3, after_name="name5"
-        )  # wrong after/before name
-
-        if isinstance(aln, Alignment) or isinstance(aln, ArrayAlignment):
-            self.assertRaises((DataError, ValueError), aln.add_seqs, aln3 + aln3)
-        else:
-            exp = set([seq for name, seq in data])
-            exp.update([seq + seq for name, seq in data3])
-            got = set()
-            for seq in aln.add_seqs(aln3 + aln3).seqs:
-                got.update([str(seq).strip()])
-            self.assertEqual(got, exp)
-
     def test_add_seqs_info(self):
         """add_seqs should preserve info attribute"""
         data = [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
@@ -745,13 +682,6 @@ def test_strand_symmetry(self):
         assert_allclose(result["seq1"].observed.array, [[3, 2], [2, 2]])
         assert_allclose(result["seq2"].observed.array, [[3, 0], [2, 1]])
 
-    def test_dotplot(self):
-        """exercising dotplot method"""
-        seqs = self.Class(data=self.brca1_data, moltype=DNA)
-        _ = seqs.dotplot()
-        with self.assertRaises(AssertionError):
-            seqs.dotplot(window=5, k=11)
-
     def test_rename_seqs(self):
         """successfully rename sequences"""
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
@@ -855,29 +785,8 @@ def test_set_repr_policy_invalid_input(self):
                 dict(num_seqs=10, num_pos=60, ref_name="longest", wrap=60),
             )
 
-    def test_set_repr_policy_valid_input(self):
-        """repr_policy should be set to new values"""
-        seqs = self.Class({"a": "AAAAA", "b": "AAA--"})
-        seqs.set_repr_policy(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
-        self.assertEqual(
-            seqs._repr_policy, dict(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
-        )
-
-        if self.Class == SequenceCollection:
-            # this class cannot slice
-            return
-
-        # should persist in slicing
-        self.assertEqual(
-            seqs[:2]._repr_policy, dict(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
-        )
-
     def test_set_wrap_affects_repr_html(self):
         """the wrap argument affects the number of columns"""
-        if self.Class == SequenceCollection:
-            # this class does not have this method
-            return
-
         # indirectly tested via counting number of occurrences of 'class="label"'
         seqs = self.Class({"a": "AAAAA", "b": "AAA--"})
         orig = seqs._repr_html_()
@@ -891,8 +800,8 @@ def test_set_wrap_affects_repr_html(self):
         os.environ[env_name] = "wrap=2"
         seqs = self.Class({"a": "AAAAA", "b": "AAA--"})
         got = seqs._repr_html_()
-        self.assertEqual(got.count(token), 3 * orig.count(token))
         os.environ.pop(env_name, None)
+        self.assertEqual(got.count(token), 3 * orig.count(token))
 
     def test_get_seq_entropy(self):
         """get_seq_entropy should get entropy of each seq"""
@@ -1042,40 +951,15 @@ def test_filter_drop_remainder(self):
         raw = {"a": "ACGACGACG", "b": "CCC---CCC", "c": "AAAA--AAA"}
         aln = self.Class(raw)
         func = _make_filter_func(aln)
-        got = aln.filtered(func, motif_length=1, log_warnings=False)
+        got = aln.filtered(func, motif_length=1, warn=False)
         self.assertEqual(len(got), 6)
         # raises an assertion if the length is not modulo
         with self.assertRaises(ValueError):
             # because alignment not modulo 2
             got = aln.filtered(func, motif_length=2, drop_remainder=False)
-        got = aln.filtered(
-            func, motif_length=2, drop_remainder=True, log_warnings=False
-        )
+        got = aln.filtered(func, motif_length=2, drop_remainder=True, warn=False)
         self.assertEqual(len(got), 4)
 
-    def test_positions(self):
-        """SequenceCollection positions property should iterate over positions, using self.names"""
-        r = self.Class({"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"})
-        r.names = ["a", "b", "c"]
-        self.assertEqual(
-            list(r.positions),
-            list(map(list, ["AAA", "AAA", "AAA", "A-A", "A--", "A--"])),
-        )
-
-    def test_take_positions(self):
-        """SequenceCollection take_positions should return new alignment w/ specified pos"""
-        self.assertEqual(
-            self.gaps.take_positions([5, 4, 0]), {"a": "AAA", "b": "A-A", "c": "--A"}
-        )
-        self.assertTrue(
-            isinstance(self.gaps.take_positions([0]), _SequenceCollectionBase)
-        )
-        # should be able to negate
-        self.assertEqual(
-            self.gaps.take_positions([5, 4, 0], negate=True),
-            {"a": "AAAA", "b": "--AA", "c": "A---"},
-        )
-
     def test_take_positions_info(self):
         aln = self.Class(
             {"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"}, info={"key": "value"}
@@ -1083,36 +967,6 @@ def test_take_positions_info(self):
         tps = aln.take_positions([5, 4, 0])
         self.assertEqual(tps.info["key"], "value")
 
-    def test_get_position_indices(self):
-        """SequenceCollection get_position_indices should return names of cols where f(col)"""
-
-        def gap_1st(x):
-            return x[0] == "-"
-
-        def gap_2nd(x):
-            return x[1] == "-"
-
-        def gap_3rd(x):
-            return x[2] == "-"
-
-        def is_list(x):
-            return isinstance(x, list)
-
-        self.gaps = self.Class(self.gaps.named_seqs, names=["a", "b", "c"])
-
-        self.assertEqual(self.gaps.get_position_indices(gap_1st), [])
-        self.assertEqual(self.gaps.get_position_indices(gap_2nd), [1, 2, 4])
-        self.assertEqual(self.gaps.get_position_indices(gap_3rd), [2, 3, 4, 5, 6])
-        self.assertEqual(self.gaps.get_position_indices(is_list), [0, 1, 2, 3, 4, 5, 6])
-        # should be able to negate
-        self.assertEqual(
-            self.gaps.get_position_indices(gap_2nd, negate=True), [0, 3, 5, 6]
-        )
-        self.assertEqual(
-            self.gaps.get_position_indices(gap_1st, negate=True), [0, 1, 2, 3, 4, 5, 6]
-        )
-        self.assertEqual(self.gaps.get_position_indices(is_list, negate=True), [])
-
     def test_take_positions_if(self):
         """SequenceCollection take_positions_if should return cols where f(col) is True"""
 
@@ -1141,6 +995,7 @@ def is_list(x):
             {"a": "AAAAA", "b": "-A-AA", "c": "-----"},
         )
         self.assertEqual(self.gaps.take_positions_if(is_list), self.gaps)
+        self.assertEqual(self.gaps.take_positions_if(is_list), self.gaps)
 
         self.assertTrue(
             isinstance(self.gaps.take_positions_if(gap_1st), _SequenceCollectionBase)
@@ -1156,93 +1011,6 @@ def is_list(x):
             {"a": "AA", "b": "A-", "c": "AA"},
         )
 
-    def test_no_degenerates(self):
-        """no_degenerates correctly excludes columns containing IUPAC ambiguity codes"""
-        data = {
-            "s1": "AAA CCC GGG TTT".replace(" ", ""),
-            "s2": "CCC GGG T-T AAA".replace(" ", ""),
-            "s3": "GGR YTT AAA CCC".replace(" ", ""),
-        }
-        aln = self.Class(data=data, moltype=DNA)
-
-        # motif length of 1, defaults - no gaps allowed
-        result = aln.no_degenerates().to_dict()
-        expect = {
-            "s1": "AA CC GG TTT".replace(" ", ""),
-            "s2": "CC GG TT AAA".replace(" ", ""),
-            "s3": "GG TT AA CCC".replace(" ", ""),
-        }
-        self.assertEqual(result, expect)
-
-        # allow gaps
-        result = aln.no_degenerates(allow_gap=True).to_dict()
-        expect = {
-            "s1": "AA CC GGG TTT".replace(" ", ""),
-            "s2": "CC GG T-T AAA".replace(" ", ""),
-            "s3": "GG TT AAA CCC".replace(" ", ""),
-        }
-        self.assertEqual(result, expect)
-
-        # motif length of 3, defaults - no gaps allowed
-        result = aln.no_degenerates(motif_length=3).to_dict()
-        expect = {
-            "s1": "TTT".replace(" ", ""),
-            "s2": "AAA".replace(" ", ""),
-            "s3": "CCC".replace(" ", ""),
-        }
-        self.assertEqual(result, expect)
-
-        # allow gaps
-        result = aln.no_degenerates(motif_length=3, allow_gap=True).to_dict()
-        expect = {
-            "s1": "GGG TTT".replace(" ", ""),
-            "s2": "T-T AAA".replace(" ", ""),
-            "s3": "AAA CCC".replace(" ", ""),
-        }
-        self.assertEqual(result, expect)
-
-        # raises ValueError if a default moltype -- with no
-        # degen characters -- is used
-        aln = self.Class(data=data)
-        self.assertRaises(ValueError, aln.no_degenerates)
-
-    def test_omit_gap_pos(self):
-        """Alignment omit_gap_pos should return alignment w/o positions of gaps"""
-        aln = self.end_gaps
-        # first, check behavior when we're just acting on the cols (and not
-        # trying to delete the naughty seqs).
-
-        # default should strip out cols that are 100% gaps
-        result = aln.omit_gap_pos()
-        self.assertEqual(result.to_dict(), {"a": "-ABC", "b": "CBA-", "c": "-DEF"})
-        # if allowed_gap_frac is 1, shouldn't delete anything
-        self.assertEqual(
-            aln.omit_gap_pos(1).to_dict(),
-            {"a": "--A-BC-", "b": "-CB-A--", "c": "--D-EF-"},
-        )
-        # if allowed_gap_frac is 0, should strip out any cols containing gaps
-        self.assertEqual(
-            aln.omit_gap_pos(0).to_dict(), {"a": "AB", "b": "BA", "c": "DE"}
-        )
-        # intermediate numbers should work as expected
-        self.assertEqual(
-            aln.omit_gap_pos(0.4).to_dict(), {"a": "ABC", "b": "BA-", "c": "DEF"}
-        )
-        self.assertEqual(
-            aln.omit_gap_pos(0.7).to_dict(), {"a": "-ABC", "b": "CBA-", "c": "-DEF"}
-        )
-
-        # when we increase the number of sequences to 6, more differences
-        # start to appear.
-        new_aln_data = aln.named_seqs.copy()
-        new_aln_data["d"] = "-------"
-        new_aln_data["e"] = "XYZXYZX"
-        new_aln_data["f"] = "AB-CDEF"
-        aln = self.Class(new_aln_data)
-
-        # if no gaps are allowed, we get None
-        self.assertEqual(aln.omit_gap_pos(0), None)
-
     def test_omit_gap_pos2(self):
         """consistency with different motif_length values"""
         data = {
@@ -1386,19 +1154,6 @@ def test_uncertainties(self):
         obs = aln.entropy_per_pos()
         assert_allclose(obs, [0, 0, 0])
 
-    def test_sample_info(self):
-        """Alignment.sample should preserver info attribute"""
-        alignment = self.Class(
-            {"seq1": "ABCDEFGHIJKLMNOP", "seq2": "ABCDEFGHIJKLMNOP"},
-            info={"key": "value"},
-        )
-        # effectively permute columns, preserving length
-        shuffled = alignment.sample()
-        self.assertEqual(shuffled.info["key"], "value")
-        # ensure length correct
-        sample = alignment.sample(10)
-        self.assertEqual(sample.info["key"], "value")
-
     def test_sample_with_replacement(self):
         # test with replacement -- just verify that it rnus
         alignment = self.Class({"seq1": "gatc", "seq2": "gatc"})
@@ -1515,41 +1270,6 @@ def test_variable_positions(self):
         self.assertEqual(aln.variable_positions(include_gap_motif=True), [])
         self.assertEqual(aln.variable_positions(include_gap_motif=False), [])
 
-    def test_to_type(self):
-        """correctly interconvert between alignment types"""
-        new_seqs = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-        array_align = self.Class == ArrayAlignment
-        # when array_align arg matches instance class, no conversion
-        # and get back self
-        aln = self.Class(data=new_seqs)
-        new = aln.to_type(array_align=array_align)
-        self.assertEqual(id(aln), id(new))
-
-        # when array_align arg does not match, should get back the opposite type
-        new = aln.to_type(array_align=not array_align)
-        self.assertFalse(isinstance(new, self.Class))
-
-        # we should be able to specify moltype and alignment
-        new = aln.to_type(array_align=not array_align, moltype=DNA)
-        self.assertEqual(new.to_dict(), new_seqs)
-        # and translate
-        self.assertEqual(
-            new.get_translation().to_dict(),
-            {"seq1": "TYV", "seq3": "TYV", "seq2": "TE-"},
-        )
-
-        # should work on ArrayAlign when just moltype changes
-        new_seqs = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---"}
-        aln = self.Class(data=new_seqs)
-        new = aln.to_type(array_align=array_align, moltype=DNA)
-        new = new.no_degenerates()  # this should not fail!
-        self.assertEqual(len(new), len(aln) - 3)
-
-        # should correctly apply to existing moltype
-        aln = self.Class(data=new_seqs, moltype=DNA)
-        new = aln.to_type(array_align=not array_align)
-        self.assertEqual(aln.moltype, new.moltype)
-
     def test_to_type_info(self):
         """interconverting between alignment types preserves info attribute"""
         new_seqs = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
@@ -1764,19 +1484,6 @@ def test_distance_matrix(self):
         # but setting drop_invalid=False allows calc
         dists = aln.distance_matrix(calc="paralinear", drop_invalid=True)
 
-    def test_quick_tree(self):
-        """quick tree method returns tree"""
-        aln = self.Class(self.brca1_data, moltype=DNA)
-        aln = aln.take_seqs(aln.names[:5])[:100]
-        tree = aln.quick_tree(calc="hamming", show_progress=False)
-        # bootstrap
-        tree = aln.quick_tree(calc="hamming", bootstrap=2, show_progress=False)
-        self.assertEqual(set(tree.get_tip_names()), set(aln.names))
-        for edge in tree.preorder():
-            if edge.is_root():
-                continue
-            self.assertIsInstance(edge.params["support"], float)
-
     def test_get_gapped_seq(self):
         """Alignment.get_gapped_seq should return seq, with gaps"""
         aln = self.Class({"seq1": "--TTT?", "seq2": "GATC??"})
@@ -1926,49 +1633,6 @@ def test_slice_align_info(self):
 class AlignmentTests(AlignmentBaseTests, TestCase):
     Class = Alignment
 
-    def test_sliced_deepcopy(self):
-        """correctly deep copy aligned objects in an alignment"""
-
-        def eval_data(data):
-            orig = self.Class(data)
-            aln = orig[2:5]
-
-            notsliced = aln.deepcopy(sliced=False)
-            sliced = aln.deepcopy(sliced=True)
-            for name in orig.names:
-                # if not sliced, underlying seq data should be same length
-                # as original
-                self.assertEqual(
-                    len(notsliced.named_seqs[name].data),
-                    len(orig.named_seqs[name].data),
-                )
-                # and the map.parent_length attributes should match
-                self.assertEqual(
-                    notsliced.named_seqs[name].map.parent_length,
-                    orig.named_seqs[name].map.parent_length,
-                )
-                # and map.parent_length and len(data) should match
-                self.assertEqual(
-                    sliced.named_seqs[name].map.parent_length,
-                    len(sliced.named_seqs[name].data),
-                )
-
-                # if sliced, seq data should be < orig
-                self.assertLess(
-                    len(sliced.named_seqs[name].data), len(orig.named_seqs[name].data)
-                )
-                # and map.parent_length and len(data) should match
-                self.assertEqual(
-                    sliced.named_seqs[name].map.parent_length,
-                    len(sliced.named_seqs[name].data),
-                )
-
-        eval_data({"seq1": "ACAACGACG", "seq2": "ACGACGACG"})
-        eval_data({"seq1": "-CAACGACG", "seq2": "ACGACGACG"})
-        eval_data({"seq1": "ACAACGAC-", "seq2": "ACGACGACG"})
-        eval_data({"seq1": "AC-ACGACG", "seq2": "ACGACGACG"})
-        eval_data({"seq1": "ACA-CGAC-", "seq2": "ACGACGACG"})
-
     def test_sliding_windows(self):
         """sliding_windows should return slices of alignments."""
         alignment = self.Class(
@@ -2010,28 +1674,6 @@ def test_with_gaps_from(self):
         self.assertEqual(r1, {"seq1": "T-G", "seq2": "TCG"})
         self.assertEqual(r2, {"seq1": "--G", "seq2": "TCG"})
 
-    def test_get_degapped_relative_to(self):
-        """should remove all columns with a gap in sequence with given name"""
-        aln = self.Class(
-            [
-                ["name1", "-AC-DEFGHI---"],
-                ["name2", "XXXXXX--XXXXX"],
-                ["name3", "YYYY-YYYYYYYY"],
-                ["name4", "-KL---MNPR---"],
-            ]
-        )
-        out_aln = self.Class(
-            [
-                ["name1", "ACDEFGHI"],
-                ["name2", "XXXX--XX"],
-                ["name3", "YY-YYYYY"],
-                ["name4", "KL--MNPR"],
-            ]
-        )
-        self.assertEqual(aln.get_degapped_relative_to("name1"), out_aln)
-
-        self.assertRaises(ValueError, aln.get_degapped_relative_to, "nameX")
-
     def test_get_degapped_relative_to_info(self):
         """should remove all columns with a gap in sequence with given name
         while preserving info attribute"""
@@ -2073,15 +1715,6 @@ def test_deepcopy2(self):
         lengths = {len(s.deepcopy(sliced=True)) for s in rc.seqs}
         self.assertEqual(lengths, {len(seqs)})
 
-    def test_dotplot(self):
-        """exercising dotplot method"""
-        aln = self.Class([["name1", "TTTTTTAAAA"], ["name2", "AAAATTTTTT"]])
-        aln = aln[2:8]
-        draw = aln.dotplot(show_progress=False)
-        expected = {("name1", "TTTTAA"), ("name2", "AATTTT")}
-        got = {(s.name, str(s)) for s in (draw.seq1, draw.seq2)}
-        self.assertEqual(got, expected)
-
     def test_to_moltype_annotations(self):
         """correctly convert to specified moltype with proper sequence annotations"""
         s1 = Sequence("TTTTTTAAAA", name="test_seq1")
@@ -2160,8 +1793,8 @@ def test_init_dict(self):
     def test_iter(self):
         """ArrayAlignment iter should iterate over positions"""
         result = list(iter(self.a2))
-        for i, j in zip(result, [list(i) for i in ["AD", "BE", "CF"]]):
-            self.assertEqual(i, j)
+        for i, j in zip(result, ["AD", "BE", "CF"]):
+            self.assertEqual(i, list(j))
 
     def test_getitem(self):
         """ArrayAlignment getitem act like standard alignment slice"""
@@ -2352,6 +1985,60 @@ def test_model_aln_to_model_aln(self):
         self.assertEqual(self.r1.name, "x")
 
 
+@pytest.mark.parametrize(
+    "raw_seq,coords",
+    (("ACGGTAAAG", ((2, 4), (5, 8))), ("CCC---CCC", ((0, 3), (6, 9)))),
+)
+def test_featuremap_slice_aligned(raw_seq, coords):
+    from cogent3.core.location import FeatureMap, Span
+
+    im, seq = DNA.make_seq(raw_seq).parse_out_gaps()
+    ia = Aligned(im, seq)
+    length = len(raw_seq)
+    fmap = FeatureMap(spans=[Span(s, e) for s, e in coords], parent_length=length)
+    expect = "".join(raw_seq[s:e] for s, e in fmap.get_coordinates())
+    got = ia[fmap]
+    assert str(got) == expect
+
+
+@pytest.mark.parametrize("cls", (ArrayAlignment, Alignment))
+def test_to_type(cls):
+    """correctly interconvert between alignment types"""
+    new_seqs = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    array_align = cls == ArrayAlignment
+    # when array_align arg matches instance class, no conversion
+    # and get back self
+    aln = cls(data=new_seqs)
+    new = aln.to_type(array_align=array_align)
+    assert id(aln) == id(new)
+
+    # when array_align arg does not match, should get back the opposite type
+    new = aln.to_type(array_align=not array_align)
+    assert not isinstance(new, cls)
+
+    # we should be able to specify moltype and alignment
+    new = aln.to_type(array_align=not array_align, moltype=DNA)
+    assert new.to_dict() == new_seqs
+    # and translate
+    assert new.get_translation().to_dict() == {
+        "seq1": "TYV",
+        "seq3": "TYV",
+        "seq2": "TE-",
+    }
+
+    # should work on ArrayAlign when just moltype changes
+    new_seqs = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---"}
+    aln = cls(data=new_seqs)
+    new = aln.to_type(array_align=array_align, moltype=DNA)
+    new = new.no_degenerates()  # this should not fail!
+    assert len(new) == len(aln) - 3
+
+    # should correctly apply to existing moltype
+    aln = cls(data=new_seqs, moltype=DNA)
+    new = aln.to_type(array_align=not array_align)
+    assert aln.moltype == new.moltype
+
+
 @pytest.mark.parametrize(
     "moltype,array_align",
     tuple(itertools.product(["rna", "dna", "protein"], [True, False])),
@@ -2367,7 +2054,7 @@ def test_upac_consensus_allow_gaps(moltype, array_align):
     assert iupac == "?CGG"
 
     # allow_gaps
-    iupac = aln.iupac_consensus(allow_gaps=False)
+    iupac = aln.iupac_consensus(allow_gap=False)
     assert iupac == "ACGG"
 
 
@@ -2609,6 +2296,13 @@ def test_copy_annotations_incompat_type_fails(seqcoll_db, gb_db):
         seqcoll_db.copy_annotations({"a": "ACGGT"})
 
 
+def test_aligned_deepcopy_sliced():
+    a = Aligned(*DNA.make_seq("GCAAGGCGCCAA").parse_out_gaps())
+    sliced = a[2:3]
+    sl_cp = sliced.deepcopy(sliced=True)
+    assert str(sl_cp) == str(sliced)
+
+
 def test_deepcopy_with_features(DATA_DIR):
     """correctly deepcopy Aligned objects in an alignment"""
     path = DATA_DIR / "brca1_5.paml"
@@ -2657,14 +2351,17 @@ def test_deepcopy_aligned(cls):
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
-def test_seq_rename_drops_annotations(cls):
+def test_seq_rename_preserves_annotations(cls):
     """rename seqs discards all annotations"""
     data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
     seqs = cls(data, moltype=DNA)
     seqs.add_feature(seqid="seq1", biotype="exon", name="fred", spans=[(3, 8)])
     assert seqs.annotation_db is not None
     new = seqs.rename_seqs(lambda x: x.upper())
-    assert not len(new.annotation_db)
+    assert len(new.annotation_db) == 1
+    assert len(list(new.get_features(biotype="exon")))
+    # using original seq name should also work
+    assert len(list(new.get_features(seqid="seq1")))
 
 
 @pytest.mark.parametrize(
@@ -2682,7 +2379,7 @@ def test_to_rich_dict_not_alignment(cls):
 
     got = aln.to_rich_dict()
 
-    data = {k: SeqView(s).to_rich_dict() for k, s in data.items()}
+    data = {k: SeqView(s, seqid=k).to_rich_dict() for k, s in data.items()}
 
     expect = {
         "seqs": {
@@ -2932,7 +2629,7 @@ def test_aligned_rich_dict(reverse):
 
     rd = seq.to_rich_dict()
     got = Aligned.from_rich_dict(rd)
-    assert str(seq) == str(got)
+    assert str(got) == str(seq)
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
@@ -3124,7 +2821,8 @@ def test_get_seq_with_sliced_rced_aln_multiple_spans(name):
     }
     aln = make_aligned_seqs(data=seqs, moltype="dna", array_align=False)
     start, stop = 1, 10
-    a1 = aln[start:stop].rc()
+    a1 = aln[start:stop]
+    a1 = a1.rc()
     got = str(a1.get_seq(name))
     dna = get_moltype("dna")
     expect = dna.complement(seqs[name][start:stop].replace("-", ""))[::-1]
@@ -3164,10 +2862,10 @@ def test_aln_rev_slice(name, array_align):
     assert not got
 
     seq = got.get_gapped_seq(name)
-    assert str(seq) == ""
+    assert not str(seq)
 
     seq = got.get_seq(name)
-    assert str(seq) == ""
+    assert not str(seq)
 
 
 @pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
@@ -3189,7 +2887,7 @@ def test_init_duplicate_keys_raises(cls):
 )
 def test_construct_unaligned_seq(seq):
     moltype = get_moltype("dna")
-    got = _construct_unaligned_seq(seq, moltype=moltype)
+    got = _construct_unaligned_seq(seq, name="seq1", moltype=moltype)
     assert isinstance(got, (Sequence, ArraySequence))
 
 
@@ -3253,7 +2951,7 @@ def _make_and_filter(cls, raw, expected, motif_length, drop_remainder):
     result = aln.filtered(
         func,
         motif_length=motif_length,
-        log_warnings=False,
+        warn=False,
         drop_remainder=drop_remainder,
     )
     assert result.to_dict() == expected
@@ -3529,3 +3227,576 @@ def test_make_case():
     data = {"a": "tata", "b": "tgtc", "c": "gcga", "d": "gaac", "e": "gagc"}
     got = make_aligned_seqs(data=data, moltype="dna")
     assert got == {k: v.upper() for k, v in data.items()}
+
+
+@pytest.fixture(scope="session")
+def brca1_data():
+    return load_aligned_seqs("data/brca1.fasta").to_dict()
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment, SequenceCollection))
+def test_dotplot(cls, brca1_data):
+    """exercising dotplot method"""
+    seqs = cls(data=brca1_data, moltype=DNA)
+    _ = seqs.dotplot()
+    with pytest.raises(AssertionError):
+        seqs.dotplot(window=5, k=11)
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_dotplot_alignment(cls):
+    """exercising dotplot method"""
+    aln = cls([["name1", "TTTTTTAAAA"], ["name2", "AAAATTTTTT"]])
+    aln = aln[2:8]
+    draw = aln.dotplot(show_progress=False)
+    expected = {("name1", "TTTTAA"), ("name2", "AATTTT")}
+    got = {(s.name, str(s)) for s in (draw.seq1, draw.seq2)}
+    assert got == expected
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_get_degapped_relative_to(cls):
+    """should remove all columns with a gap in sequence with given name"""
+    aln = cls(
+        [
+            ["name1", "-AC-DEFGHI---"],
+            ["name2", "XXXXXX--XXXXX"],
+            ["name3", "YYYY-YYYYYYYY"],
+            ["name4", "-KL---MNPR---"],
+        ]
+    )
+    expect = dict(
+        [
+            ["name1", "ACDEFGHI"],
+            ["name2", "XXXX--XX"],
+            ["name3", "YY-YYYYY"],
+            ["name4", "KL--MNPR"],
+        ]
+    )
+    result = aln.get_degapped_relative_to("name1")
+    assert result.to_dict() == expect
+
+    with pytest.raises(ValueError):
+        aln.get_degapped_relative_to("nameX")
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        {"seq1": "ACAACGACG", "seq2": "ACGACGACG"},
+        {"seq1": "-CAACGACG", "seq2": "ACGACGACG"},
+        {"seq1": "ACAACGAC-", "seq2": "ACGACGACG"},
+        {"seq1": "AC-ACGACG", "seq2": "ACGACGACG"},
+        {"seq1": "ACA-CGAC-", "seq2": "ACGACGACG"},
+    ),
+)
+@pytest.mark.parametrize("name", ("seq1", "seq2"))
+@pytest.mark.parametrize("rev", (False, True))
+def test_sliced_deepcopy(data, name, rev):
+    """correctly deep copy aligned objects in an alignment"""
+
+    orig = Alignment(data, moltype="dna")
+    slice_start, slice_end = 3, 5
+    aln = orig[slice_start:slice_end]
+    if rev:
+        aln = aln.rc()
+
+    notsliced = aln.deepcopy(sliced=False)
+    # the annotation offsets should match original object
+    assert {s.data.annotation_offset for s in notsliced.seqs} == {
+        s.data.annotation_offset for s in aln.seqs
+    }
+    sliced = aln.deepcopy(sliced=True)
+    assert sliced.to_dict() == notsliced.to_dict()
+    sliced_seq = sliced.named_seqs[name]
+    notsliced_seq = notsliced.named_seqs[name]
+    assert str(sliced_seq) == str(notsliced_seq)
+
+    # the annotation offsets should match original object
+    assert {s.data.annotation_offset for s in sliced.seqs} == {
+        s.data.annotation_offset for s in aln.seqs
+    }
+    # if not sliced in copy, underlying seq data is identical to original
+    assert (
+        notsliced.named_seqs[name].data._seq.seq is orig.named_seqs[name].data._seq.seq
+    )
+    # but not the same for sliced
+    assert (
+        sliced.named_seqs[name].data._seq.seq is not orig.named_seqs[name].data._seq.seq
+    )
+
+    assert sliced.named_seqs[name].map.parent_length == len(
+        str(sliced_seq).replace("-", "")
+    )
+    assert notsliced.named_seqs[name].map.parent_length == len(
+        str(notsliced_seq).replace("-", "")
+    )
+    # and map.parent_length and len(data) should match
+    assert sliced.named_seqs[name].map.parent_length == len(
+        sliced.named_seqs[name].data
+    )
+
+    # if sliced, seq data should be < orig
+    assert len(sliced.named_seqs[name].data) < len(orig.named_seqs[name].data)
+
+
+def test_aligned_deepcopy_sliced_map_matches_data():
+    m, seq = DNA.make_seq("ACAACGACG", name="seq1").parse_out_gaps()
+    aligned = Aligned(m, seq)
+    sliced = aligned[3:5]
+    sliced_copy = sliced.deepcopy(sliced=True)
+    assert sliced_copy.map.parent_length == len(sliced_copy.data)
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
+def test_iter_selected(cls):
+    """SequenceCollection iter_selected() should iterate over items in correct order"""
+    # should work if one row
+    one_seq = cls({"a": "AAAAA"})
+    ragged_padded = cls({"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"})
+    ordered1 = cls({"a": "AAAAA", "b": "BBBBB"}, names=["a", "b"])
+    ordered2 = cls({"a": "AAAAA", "b": "BBBBB"}, names=["b", "a"])
+
+    assert list(one_seq.iter_selected()) == (["A"] * 5)
+    # should take order into account
+    assert list(ordered1.iter_selected()) == (["A"] * 5 + ["B"] * 5)
+    assert list(ordered2.iter_selected()) == (["B"] * 5 + ["A"] * 5)
+    # should allow row and/or col specification
+    r = ragged_padded
+    assert list(r.iter_selected(seq_order=["c", "b"], pos_order=[5, 1, 3])) == list(
+        "-AA-A-"
+    )
+    # should not interfere with superclass iteritems()
+    i = list(r.named_seqs.items())
+    i.sort()
+    assert i == ([("a", "AAAAAA"), ("b", "AAA---"), ("c", "AAAA--")])
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_take_positions(cls):
+    """SequenceCollection take_positions should return new alignment w/ specified pos"""
+    gaps = cls({"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"})
+    assert gaps.take_positions([5, 4, 0]) == {"a": "AAA", "b": "A-A", "c": "--A"}
+    assert isinstance(gaps.take_positions([0]), _SequenceCollectionBase)
+
+    # should be able to negate
+    assert gaps.take_positions([5, 4, 0], negate=True) == {
+        "a": "AAAA",
+        "b": "--AA",
+        "c": "A---",
+    }
+
+
+@pytest.mark.parametrize("array_align", (True, False))
+@pytest.mark.parametrize(
+    "seq_moltype", (("ACCT--GT", "dna"), ("ACCT--GU", "rna"), ("MTST--T", "protein"))
+)
+def test_alignment_propogates_seqid_to_seqview(array_align, seq_moltype):
+    data = {"seq1": seq_moltype[0], "seq2": seq_moltype[0], "seq3": seq_moltype[0]}
+    aln = make_aligned_seqs(data, moltype=seq_moltype[1], array_align=array_align)
+
+    if array_align:
+        assert aln.seqs[0]._seq.seqid == "seq1"
+    else:
+        assert aln.seqs[0].data._seq.seqid == "seq1"
+
+
+@pytest.mark.parametrize("cls", (str, bytes))
+def test_construct_unaligned_seq_propogates_seqid(cls):
+    data = "ACGT"
+    if cls is bytes:
+        seq = cls(data, "utf8")
+    else:
+        seq = cls(data)
+    got = _construct_unaligned_seq(seq, name="seq1", moltype=DNA)
+    assert got._seq.seqid == "seq1"
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_sample_info(cls):
+    """Alignment.sample should preserver info attribute"""
+    alignment = cls(
+        {"seq1": "ABCDEFGHIJKLMNOP", "seq2": "ABCDEFGHIJKLMNOP"},
+        info={"key": "value"},
+    )
+    # effectively permute columns, preserving length
+    shuffled = alignment.sample()
+    assert shuffled.info["key"] == "value"
+    # ensure length correct
+    sample = alignment.sample(10)
+    assert sample.info["key"] == "value"
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment, ArrayAlignment))
+def test_to_json(cls):
+    """roundtrip of to_json produces correct dict"""
+    aln = cls({"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"})
+    txt = aln.to_json()
+    got = json.loads(txt)
+    expect = aln.to_rich_dict()
+    assert got == expect
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_get_position_indices(cls):
+    """get_position_indices should return names of cols where f(col)"""
+
+    def gap_1st(x):
+        return x[0] == "-"
+
+    def gap_2nd(x):
+        return x[1] == "-"
+
+    def gap_3rd(x):
+        return x[2] == "-"
+
+    def is_list(x):
+        return isinstance(x, list)
+
+    gaps = cls({"a": "AAAAAAA", "b": "A--A-AA", "c": "AA-----"}, names=["a", "b", "c"])
+
+    assert gaps.get_position_indices(gap_1st) == []
+    assert gaps.get_position_indices(gap_2nd) == [1, 2, 4]
+    assert gaps.get_position_indices(gap_3rd) == [2, 3, 4, 5, 6]
+    assert gaps.get_position_indices(is_list) == [0, 1, 2, 3, 4, 5, 6]
+    # should be able to negate
+    assert gaps.get_position_indices(gap_2nd, negate=True) == [0, 3, 5, 6]
+    assert gaps.get_position_indices(gap_1st, negate=True) == [0, 1, 2, 3, 4, 5, 6]
+    assert gaps.get_position_indices(is_list, negate=True) == []
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_positions(cls):
+    """positions property should iterate over positions, using self.names"""
+    r = cls({"a": "AAAAAA", "b": "AAA---", "c": "AAAA--"})
+    r.names = ["a", "b", "c"]
+    expect = [list(v) for v in ("AAA", "AAA", "AAA", "A-A", "A--", "A--")]
+    assert list(r.positions) == expect
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment, SequenceCollection))
+def test_set_repr_policy_valid_input(cls):
+    """repr_policy should be set to new values"""
+    seqs = cls({"a": "AAAAA", "b": "AAA--"})
+    seqs.set_repr_policy(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
+    assert seqs._repr_policy == dict(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_set_repr_policy_valid_input_slices(cls):
+    """repr_policy should be set to new values"""
+    seqs = cls({"a": "AAAAA", "b": "AAA--"})
+    seqs.set_repr_policy(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
+    # should persist in slicing
+    sliced = seqs[:2]
+    assert sliced._repr_policy == dict(num_seqs=5, num_pos=40, ref_name="a", wrap=10)
+
+
+def test_array_align_error_with_mixed_length():
+    data = dict(s1="ACGG", s2="A-G")
+    with pytest.raises(ValueError, match=".* not all the same length.*"):
+        make_aligned_seqs(data=data)
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment, SequenceCollection))
+def test_add_seqs(cls):
+    """add_seqs should return an alignment with the new sequences appended or inserted"""
+    data = [("name1", "AAA"), ("name2", "AAA"), ("name3", "AAA"), ("name4", "AAA")]
+    data1 = [("name1", "AAA"), ("name2", "AAA")]
+    data2 = [("name3", "AAA"), ("name4", "AAA")]
+    data3 = [("name5", "BBB"), ("name6", "CCC")]
+    aln = cls(data)
+    aln3 = cls(data3)
+
+    out_aln = aln.add_seqs(aln3)
+    # test append at the end
+    assert str(out_aln) == str(cls(data + data3))
+
+    out_aln = aln.add_seqs(aln3, before_name="name3")
+    assert str(out_aln) == str(cls(data1 + data3 + data2))
+
+    # test insert before
+
+    out_aln = aln.add_seqs(aln3, after_name="name2")
+    assert str(out_aln) == str(cls(data1 + data3 + data2))  # test insert after
+
+    out_aln = aln.add_seqs(aln3, before_name="name1")
+    # test if insert before first seq works
+    assert str(out_aln) == str(cls(data3 + data))
+
+    out_aln = aln.add_seqs(aln3, after_name="name4")
+    # test if insert after last seq works
+    assert str(out_aln) == str(cls(data + data3))
+
+    with pytest.raises(ValueError):
+        # wrong after/before name
+        aln.add_seqs(aln3, before_name="name5")
+
+    with pytest.raises(ValueError):
+        # wrong after/before name
+        aln.add_seqs(aln3, after_name="name5")
+
+    if isinstance(aln, Alignment) or isinstance(aln, ArrayAlignment):
+        with pytest.raises((DataError, ValueError)):
+            aln.add_seqs(aln3 + aln3)
+    else:
+        exp = set([seq for name, seq in data])
+        exp.update([seq + seq for name, seq in data3])
+        got = set()
+        for seq in aln.add_seqs(aln3 + aln3).seqs:
+            got.update([str(seq).strip()])
+        assert got == exp
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_omit_gap_pos(cls):
+    """Alignment omit_gap_pos should return alignment w/o positions of gaps"""
+    aln = cls({"a": "--A-BC-", "b": "-CB-A--", "c": "--D-EF-"}, names=["a", "b", "c"])
+    # first, check behavior when we're just acting on the cols (and not
+    # trying to delete the naughty seqs).
+
+    # default should strip out cols that are 100% gaps
+    result = aln.omit_gap_pos()
+    assert result.to_dict() == {"a": "-ABC", "b": "CBA-", "c": "-DEF"}
+    # if allowed_gap_frac is 1, shouldn't delete anything
+    assert aln.omit_gap_pos(1).to_dict() == {
+        "a": "--A-BC-",
+        "b": "-CB-A--",
+        "c": "--D-EF-",
+    }
+
+    # if allowed_gap_frac is 0, should strip out any cols containing gaps
+    assert aln.omit_gap_pos(0).to_dict() == {"a": "AB", "b": "BA", "c": "DE"}
+    # intermediate numbers should work as expected
+    assert aln.omit_gap_pos(0.4).to_dict() == {"a": "ABC", "b": "BA-", "c": "DEF"}
+    assert aln.omit_gap_pos(0.7).to_dict() == {"a": "-ABC", "b": "CBA-", "c": "-DEF"}
+
+    # when we increase the number of sequences to 6, more differences
+    # start to appear.
+    new_aln_data = aln.named_seqs.copy()
+    new_aln_data["d"] = "-------"
+    new_aln_data["e"] = "XYZXYZX"
+    new_aln_data["f"] = "AB-CDEF"
+    aln = cls(new_aln_data)
+
+    # if no gaps are allowed, we get None
+    assert aln.omit_gap_pos(0) is None
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+def test_no_degenerates(cls):
+    """no_degenerates correctly excludes columns containing IUPAC ambiguity codes"""
+    data = {
+        "s1": "AAA CCC GGG TTT".replace(" ", ""),
+        "s2": "CCC GGG T-T AAA".replace(" ", ""),
+        "s3": "GGR YTT AAA CCC".replace(" ", ""),
+    }
+    aln = cls(data=data, moltype=DNA)
+
+    # motif length of 1, defaults - no gaps allowed
+    result = aln.no_degenerates().to_dict()
+    expect = {
+        "s1": "AA CC GG TTT".replace(" ", ""),
+        "s2": "CC GG TT AAA".replace(" ", ""),
+        "s3": "GG TT AA CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # allow gaps
+    result = aln.no_degenerates(allow_gap=True).to_dict()
+    expect = {
+        "s1": "AA CC GGG TTT".replace(" ", ""),
+        "s2": "CC GG T-T AAA".replace(" ", ""),
+        "s3": "GG TT AAA CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # motif length of 3, defaults - no gaps allowed
+    result = aln.no_degenerates(motif_length=3, allow_gap=False).to_dict()
+    expect = {
+        "s1": "TTT".replace(" ", ""),
+        "s2": "AAA".replace(" ", ""),
+        "s3": "CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+    # allow gaps
+    result = aln.no_degenerates(motif_length=3, allow_gap=True).to_dict()
+    expect = {
+        "s1": "GGG TTT".replace(" ", ""),
+        "s2": "T-T AAA".replace(" ", ""),
+        "s3": "AAA CCC".replace(" ", ""),
+    }
+    assert result == expect
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+@pytest.mark.parametrize("moltype", ("bytes", "text"))
+def test_no_degenerates_invalid_moltype(cls, moltype):
+    # raises ValueError if a default moltype -- with no
+    # degen characters -- is used
+    data = {
+        "s1": "AAA CCC GGG TTT".replace(" ", ""),
+        "s2": "CCC GGG T-T AAA".replace(" ", ""),
+        "s3": "GGR YTT AAA CCC".replace(" ", ""),
+    }
+
+    aln = cls(data=data, moltype=moltype)
+    with pytest.raises(ValueError):
+        aln.no_degenerates()
+
+
+@pytest.mark.parametrize("cls", (Alignment, ArrayAlignment))
+@pytest.mark.parametrize("calc", ("hamming", None))
+def test_quick_tree(cls, calc, brca1_data):
+    """quick tree method returns tree"""
+    aln = cls(brca1_data, moltype=DNA)[:100]
+    aln = aln.take_seqs(["Human", "Rhesus", "HowlerMon", "Galago", "Mouse"])
+    kwargs = dict(show_progress=False)
+    if calc:
+        kwargs["calc"] = calc
+
+    # bootstrap
+    tree = aln.quick_tree(bootstrap=2, **kwargs)
+    assert set(tree.get_tip_names()) == set(aln.names)
+    types = {
+        type(float(edge.params["support"]))
+        for edge in tree.preorder()
+        if not edge.is_root()
+    }
+    assert types == {float}
+
+
+@pytest.mark.parametrize("raw", ("-AAAGGGGGAACCCT", "AAAGGGGGAACCCT"))
+def test_slice_aligned(raw):
+    imap, seq = DNA.make_seq(raw, name="x").parse_out_gaps()
+    al = Aligned(imap, seq)
+    sliced = al[:-3]
+    assert str(sliced) == raw[:-3]
+
+
+def test_slice_aligned_featuremap_allgap():
+    from cogent3.core.location import FeatureMap, LostSpan
+
+    imap, seq = DNA.make_seq("AAAGGGGGAACCCT", name="x").parse_out_gaps()
+    al = Aligned(imap, seq)
+    fmap = FeatureMap(spans=[LostSpan(4)], parent_length=0)
+    sliced = al[fmap]
+    assert not sliced
+
+
+def test_slice_aligned_featuremap_multi_spans():
+    from cogent3.core.location import FeatureMap
+
+    #                    1111111
+    #          01234567890123456
+    #           ***   **    ***
+    raw_seq = "AAAGG--GGG-AACCCT"
+    #          01234  567 890123
+    #                       1111
+    imap, seq = DNA.make_seq(raw_seq, name="x").parse_out_gaps()
+    al = Aligned(imap, seq)
+    fmap = FeatureMap.from_locations(
+        locations=[(1, 4), (7, 9), (13, 16)], parent_length=len(raw_seq)
+    )
+    sliced = al[fmap]
+    assert str(sliced) == "AAGGGCCC"
+
+
+def test_sequence_collection_repr():
+    data = {
+        "ENSMUSG00000056468": "GCCAGGGGGAAAAGGGAGAA",
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+    }
+    seqs = SequenceCollection(data=data, moltype=DNA)
+    assert (
+        repr(seqs)
+        == "2x (ENSMUSG00000039616[GCCCTTCAAA...], ENSMUSG00000056468[GCCAGGGGGA...]) dna seqcollection"
+    )
+
+    data = {
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+        "ENSMUSG00000056468": "GCCAGGGGGAAAAGGGAGAA",
+    }
+    seqs = SequenceCollection(data=data, moltype=DNA)
+    assert (
+        repr(seqs)
+        == "2x (ENSMUSG00000039616[GCCCTTCAAA...], ENSMUSG00000056468[GCCAGGGGGA...]) dna seqcollection"
+    )
+
+    data = {
+        "a": "TCGAT",
+    }
+    seqs = SequenceCollection(data=data, moltype=DNA)
+    assert repr(seqs) == "1x (a[TCGAT]) dna seqcollection"
+
+    data = {
+        "a": "TCGAT" * 2,
+    }
+    seqs = SequenceCollection(data=data, moltype=DNA)
+    assert repr(seqs) == "1x (a[TCGATTCGAT]) dna seqcollection"
+
+    data = {
+        "a": "A" * 11,
+        "b": "B" * 3,
+        "c": "C" * 3,
+        "d": "D" * 11,
+        "e": "E" * 8,
+    }
+    seqs = SequenceCollection(data=data, moltype=ASCII)
+    assert repr(seqs) == "5x (b[BBB], ..., d[DDDDDDDDDD...]) text seqcollection"
+
+
+@pytest.mark.parametrize("cls", (ArrayAlignment, Alignment))
+def test_alignment_repr(cls):
+    data = {
+        "ENSMUSG00000056468": "GCCAGGGGGAAAA",
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+    }
+    seqs = cls(data=data, moltype=DNA)
+    assert (
+        repr(seqs)
+        == "2 x 13 dna alignment: ENSMUSG00000056468[GCCAGGGGGA...], ENSMUSG00000039616[GCCCTTCAAA...]"
+    )
+
+    data = {
+        "ENSMUSG00000039616": "GCCCTTCAAATTT",
+        "ENSMUSG00000056468": "GCCAGGGGGAAAA",
+    }
+    seqs = cls(data=data, moltype=DNA)
+    assert (
+        repr(seqs)
+        == "2 x 13 dna alignment: ENSMUSG00000039616[GCCCTTCAAA...], ENSMUSG00000056468[GCCAGGGGGA...]"
+    )
+
+    data = {
+        "a": "TCGAT",
+    }
+    seqs = cls(data=data, moltype=DNA)
+    assert repr(seqs) == "1 x 5 dna alignment: a[TCGAT]"
+
+    data = {
+        "a": "TCGAT" * 2,
+    }
+    seqs = cls(data=data, moltype=DNA)
+    assert repr(seqs) == "1 x 10 dna alignment: a[TCGATTCGAT]"
+
+    data = {
+        "a": "A" * 11,
+        "b": "B" * 11,
+        "c": "C" * 11,
+        "d": "D" * 11,
+        "e": "E" * 11,
+    }
+    seqs = cls(data=data, moltype=ASCII)
+    assert (
+        repr(seqs)
+        == "5 x 11 text alignment: a[AAAAAAAAAA...], b[BBBBBBBBBB...], c[CCCCCCCCCC...], ..."
+    )
+
+
+@pytest.mark.parametrize("cls", (ArrayAlignment, Alignment, SequenceCollection))
+def test_empty_data(cls):
+    with pytest.raises(ValueError) as e:
+        _ = cls(())
+    assert str(e.value) == f"{cls.__name__} must take at least one sequence."
diff --git a/tests/test_core/test_alphabet.py b/tests/test_core/test_alphabet.py
index e2ef141f3..b0206fad9 100644
--- a/tests/test_core/test_alphabet.py
+++ b/tests/test_core/test_alphabet.py
@@ -2,10 +2,14 @@
 
 Note: individual alphabets are typically in MolType and are tested there.
 """
+
 import pickle
 
 from unittest import TestCase
 
+import numpy
+import pytest
+
 from numpy import unravel_index
 from numpy.testing import assert_equal
 
@@ -306,5 +310,16 @@ def test_unpack_arrays(self):
         assert_equal(result, array([[0, 1, 2, 0], [3, 3, 1, 0], [1, 1, 0, 0]]))
 
 
-if __name__ == "__main__":
-    main()
+@pytest.mark.parametrize("data", ("AUGGA", b"AUGGA"))
+def test_to_indices(data):
+    alphabet = RNA.alphabets.degen_gapped
+    got = alphabet.to_indices(data)
+    assert got.tolist() == [2, 0, 3, 3, 2]
+
+
+@pytest.mark.parametrize("cls", (list, numpy.array, tuple))
+def test_from_indices(cls):
+    data = cls([2, 0, 3, 3, 2])
+    alphabet = RNA.alphabets.degen_gapped
+    got = alphabet.from_indices(data)
+    assert got == "AUGGA"
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index 0fcfcbc1a..a1420f37c 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -3,7 +3,8 @@
 import pytest
 
 from cogent3 import DNA, load_seq, make_aligned_seqs, make_unaligned_seqs
-from cogent3.core.location import Map, Span
+from cogent3.core.alignment import Alignment, SequenceCollection
+from cogent3.core.location import FeatureMap, Span
 
 
 def makeSampleSequence(name, with_gaps=False):
@@ -90,17 +91,6 @@ def test_span(self):
         assert forward.reversed_relative_to(100) == reverse
         assert reverse.reversed_relative_to(100) == forward
 
-    def test_map(self):
-        """reversing a map with multiple spans should preserve span relative
-        order"""
-        forward = [Span(20, 30), Span(40, 50)]
-        fmap = Map(spans=forward, parent_length=100)
-        fmap_reversed = fmap.nucleic_reversed()
-        reverse = [Span(70, 80, reverse=True), Span(50, 60, reverse=True)]
-        rmap = Map(spans=reverse, parent_length=100)
-        for i in range(2):
-            self.assertEqual(fmap_reversed.spans[i], rmap.spans[i])
-
 
 @pytest.mark.parametrize("alignment", (False, True))
 def test_constructing_collections(alignment):
@@ -299,3 +289,75 @@ def test_gbdb_get_children_get_parent(DATA_DIR):
     (child,) = list(orig.get_children("CDS"))
     parent, *_ = list(child.get_parent())
     assert parent == orig
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_features_survives_seq_rename(rev):
+    segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
+
+    seq = DNA.make_seq("".join(segments), name="original")
+    gene = seq.add_feature(biotype="gene", name="gene1", spans=[(10, 20), (25, 30)])
+    gene_expect = str(seq[10:20]) + str(seq[25:30])
+    assert str(gene.get_slice()) == gene_expect
+    domain = seq.add_feature(
+        biotype="domain", name="domain1", spans=[(20, 25)], strand="-"
+    )
+    domain_expect = str(seq[20:25].rc())
+    domain_got = domain.get_slice()
+    assert str(domain_got) == domain_expect
+    sliced = seq[5:-3]
+    sliced.name = "sliced"
+    sliced = sliced.rc() if rev else sliced
+
+    got = list(sliced.get_features(name="gene1"))[0]
+    got = got.get_slice()
+    assert str(got) == gene_expect
+
+    got = list(sliced.get_features(name="domain1"))[0]
+    got = got.get_slice()
+    assert str(got) == domain_expect
+
+
+@pytest.mark.parametrize("rev", (False, True))
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_features_survives_aligned_seq_rename(rev, cls):
+    segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
+
+    seqs = cls({"original": "".join(segments)}, moltype="dna")
+    seqs.annotation_db.add_feature(
+        seqid="original", biotype="gene", name="gene1", spans=[(10, 20), (25, 30)]
+    )
+    seqs.annotation_db.add_feature(
+        seqid="original", biotype="domain", name="domain1", spans=[(20, 25)], strand="-"
+    )
+    seqs = seqs.rename_seqs(lambda x: "newname")
+    assert seqs.names == ["newname"]
+    seqs = seqs.rc() if rev else seqs
+    # quite different behaviour from Alignment and SequenceCollection
+    # so we convert to string to make comparison simpler
+    got = list(seqs.get_features(name="gene1"))[0]
+    sliced = str(got.get_slice()).splitlines()[-1]
+    assert sliced == "C" * 15
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_features_invalid_seqid(cls):
+    segments = ["A" * 10, "C" * 10, "T" * 5, "C" * 5, "A" * 5]
+
+    seqs = cls({"original": "".join(segments)}, moltype="dna")
+    seqs.annotation_db.add_feature(
+        seqid="original", biotype="domain", name="domain1", spans=[(20, 25)], strand="-"
+    )
+    with pytest.raises(ValueError):
+        # seqid does not exist
+        list(seqs.get_features(name="gene1", seqid="blah"))
+
+
+def test_map():
+    """reversing a map with multiple spans should match hand-crafted"""
+    forward = [Span(20, 30), Span(40, 50)]
+    fmap = FeatureMap(spans=forward, parent_length=100)
+    fmap_reversed = fmap.nucleic_reversed()
+    reverse = [Span(50, 60), Span(70, 80)]
+    rmap = FeatureMap(spans=reverse, parent_length=100)
+    assert fmap_reversed.get_coordinates() == rmap.get_coordinates()
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index b2a106921..a0910bb13 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -1,3 +1,6 @@
+import io
+import warnings
+
 import numpy
 import pytest
 
@@ -8,7 +11,9 @@
     GffAnnotationDb,
     SupportsFeatures,
     _matching_conditions,
+    _rename_column_if_exists,
     load_annotations,
+    update_file_format,
 )
 from cogent3.core.sequence import Sequence
 from cogent3.parse.genbank import MinimalGenbankParser
@@ -270,7 +275,7 @@ def test_empty_data():
 
 
 # testing GenBank files
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
 def gb_db(DATA_DIR):
     return load_annotations(path=DATA_DIR / "annotated_seq.gb")
 
@@ -292,7 +297,7 @@ def test_gb_get_children(gb_db, parent_biotype, name):
             name=name,
             exclude_biotype=parent_biotype,
             start=coords.min(),
-            end=coords.max(),
+            stop=coords.max(),
         )
     )[0]
     assert child["biotype"] != parent["biotype"]
@@ -308,7 +313,7 @@ def test_gb_get_parent(gb_db):
             name=cds_id,
             exclude_biotype="CDS",
             start=coords.min(),
-            end=coords.max(),
+            stop=coords.max(),
         )
     )[0]
     assert parent["biotype"] != cds["biotype"]
@@ -405,13 +410,17 @@ def test_feature_nucleic():
     from cogent3 import make_seq
     from cogent3.core import location as loc
 
+    #                              111111
+    #                    0123456789012345
     seq = make_seq("AACCTTTGGGGAATTT", moltype="dna")
-    mmap = loc.Map(locations=[(4, 7), (11, 13)], parent_length=16)
-    expect = seq[mmap.reversed()]
+    mmap = loc.FeatureMap.from_locations(
+        locations=[(4, 7), (10, 12)], parent_length=len(seq)
+    )
+    expect = seq[mmap]
 
     rcseq = seq.rc()
-    rmap = mmap.nucleic_reversed().reversed()
-    got = rcseq[rmap]
+    rmap = mmap.nucleic_reversed()
+    got = rcseq[rmap].rc()
     assert str(got) == str(expect)
 
 
@@ -474,8 +483,8 @@ def test_get_features_matching_start_stop(DATA_DIR, seq):
 
 
 def test_matching_conditions():
-    got, _ = _matching_conditions({"start": 1, "end": 5}, allow_partial=True)
-    expect = "((start >= 1 AND end <= 5) OR (start <= 1 AND end > 1) OR (start < 5 AND end >= 5) OR (start <= 1 AND end >= 5))"
+    got, _ = _matching_conditions({"start": 1, "stop": 5}, allow_partial=True)
+    expect = "((start >= 1 AND stop <= 5) OR (start <= 1 AND stop > 1) OR (start < 5 AND stop >= 5) OR (start <= 1 AND stop >= 5))"
     assert got == expect
 
 
@@ -568,7 +577,7 @@ def test_get_slice():
         "spans": [
             (5, 10),
         ],
-        "reversed": False,
+        "strand": "+",
     }
     feature = seq.make_feature(feature_data)
     got = feature.get_slice()
@@ -919,6 +928,165 @@ def test_write(gb_db, tmp_path):
     assert isinstance(got, GenbankAnnotationDb)
 
 
+def convert_to_old_np_format(data: bytes) -> bytes:
+    with io.BytesIO(data) as stream:
+        output = numpy.load(stream).tobytes()
+    return output
+
+
+def convert_spans_column(db, table_name):
+    # Convert the database to the old numpy format.
+    conn = db.db
+    conn.create_function("convert_to_old_np_format", 1, convert_to_old_np_format)
+    cursor = conn.cursor()
+    cursor.execute(f"UPDATE {table_name} SET spans = convert_to_old_np_format(spans);")
+    conn.commit()
+
+
+@pytest.mark.parametrize(
+    "db_name,cls", (("gb_db", GenbankAnnotationDb), ("gff_db", GffAnnotationDb))
+)
+def test_read_old_format(db_name, cls, tmp_path, request):
+    db = request.getfixturevalue(db_name)
+
+    path = tmp_path / f"old_format_{db_name}.db"
+
+    old_tables = db.to_rich_dict()["tables"]
+
+    table_name = db_name.split("_")[0]
+    convert_spans_column(db, table_name)
+
+    db.write(path)
+
+    new_db = cls(source=path)
+    with pytest.warns(UserWarning):
+        new_tables = new_db.to_rich_dict()["tables"]
+    assert new_tables == old_tables
+
+
+@pytest.mark.parametrize(
+    "db_name,cls,backup",
+    (
+        ("gb_db", GenbankAnnotationDb, True),
+        ("gff_db", GffAnnotationDb, True),
+        ("gb_db", GenbankAnnotationDb, False),
+        ("gff_db", GffAnnotationDb, False),
+    ),
+)
+def test_update_file_format(db_name, cls, backup, tmp_path, request):
+    db = request.getfixturevalue(db_name)
+    old_tables = db.to_rich_dict()["tables"]
+
+    path = tmp_path / f"old_format_{db_name}.db"
+
+    # Convert to old numpy format
+    table_name = db_name.split("_")[0]
+    convert_spans_column(db, table_name)
+    db.write(path)
+
+    with open(path, "rb") as f:
+        old_format_bytes = f.read()
+
+    # The file should be updated without warning
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        update_file_format(path, cls, backup=backup)
+
+    backup_path = tmp_path / f"old_format_{db_name}.db.bak"
+    if backup:
+        # Check the backup hasn't been modified
+        with open(backup_path, "rb") as f:
+            assert old_format_bytes == f.read()
+    else:
+        # A backup file should not exist
+        assert not backup_path.exists()
+
+    # The file should have been modified
+    with open(path, "rb") as f:
+        assert old_format_bytes != f.read()
+
+    # Reading the new file (when spans is loaded) should not produce warnings
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+
+        new_db = cls(source=path)
+        new_tables = new_db.to_rich_dict()["tables"]
+
+    # The tables should remain the same
+    assert old_tables == new_tables
+
+
+@pytest.mark.parametrize(
+    "db_name,cls",
+    (("gb_db", GenbankAnnotationDb), ("gff_db", GffAnnotationDb)),
+)
+def test_update_file_format_twice_fails(db_name, cls, tmp_path, request):
+    db = request.getfixturevalue(db_name)
+
+    path = tmp_path / f"old_format_{db_name}.db"
+
+    # Convert to old numpy format
+    table_name = db_name.split("_")[0]
+    convert_spans_column(db, table_name)
+    db.write(path)
+
+    update_file_format(path, cls, backup=True)
+    with pytest.raises(FileExistsError):
+        update_file_format(path, cls, backup=True)
+
+
+@pytest.mark.parametrize(
+    "db_name,cls,backup",
+    (
+        ("gb_db", GenbankAnnotationDb, True),
+        ("gff_db", GffAnnotationDb, True),
+        ("gb_db", GenbankAnnotationDb, False),
+        ("gff_db", GffAnnotationDb, False),
+    ),
+)
+def test_update_file_format_does_not_modify_correct_file(
+    db_name, cls, backup, tmp_path, request
+):
+    db = request.getfixturevalue(db_name)
+
+    path = tmp_path / f"correct_format_{db_name}.db"
+    db.write(path)
+
+    with open(path, "rb") as f:
+        old_bytes = f.read()
+
+    # Updating the file format should not produce warnings
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        update_file_format(path, cls, backup=backup)
+
+    # The file should have remained the same
+    with open(path, "rb") as f:
+        assert old_bytes == f.read()
+
+
+@pytest.mark.parametrize(
+    "db_name,cls,backup",
+    (
+        ("gb_db", GenbankAnnotationDb, True),
+        ("gff_db", GffAnnotationDb, True),
+        ("gb_db", GenbankAnnotationDb, False),
+        ("gff_db", GffAnnotationDb, False),
+    ),
+)
+def test_update_file_format_fake_path(db_name, cls, backup, tmp_path):
+    path = tmp_path / f"non_existent_{db_name}.db"
+    with pytest.raises(OSError):
+        update_file_format(path, cls, backup=backup)
+
+    # The path should not have been created in the process
+    assert not path.exists()
+    if backup:
+        # The backup file path should also not exist
+        backup_path = tmp_path / f"non_existent_{db_name}.db.bak"
+        assert not backup_path.exists()
+
+
 @pytest.fixture(scope="function")
 def tmp_dir(tmp_path_factory):
     return tmp_path_factory.mktemp("annotations")
@@ -937,6 +1105,48 @@ def test_load_anns_with_write(DATA_DIR, tmp_dir):
     assert got_data["tables"] == expect_data["tables"]
 
 
+def test_gff_end_renamed_to_stop(gff_db, tmp_path):
+    bad_col_path = tmp_path / "bad_col.gffdb"
+
+    correct_rich_dict = gff_db.to_rich_dict()
+    del correct_rich_dict["init_args"]
+
+    for table_name in gff_db.table_names:
+        # Convert in memory db stop column to the old end name
+        _rename_column_if_exists(gff_db.db, table_name, "stop", "end")
+
+    gff_db.write(tmp_path / bad_col_path)
+
+    # Test that end column is renamed to stop on construction
+    loaded_gff_db = GffAnnotationDb(source=bad_col_path)
+
+    new_rich_dict = loaded_gff_db.to_rich_dict()
+    del new_rich_dict["init_args"]
+
+    assert new_rich_dict == correct_rich_dict
+
+
+def test_gb_end_renamed_to_stop(gb_db, tmp_path):
+    bad_col_path = tmp_path / "bad_col.gbdb"
+
+    correct_rich_dict = gb_db.to_rich_dict()
+    del correct_rich_dict["init_args"]
+
+    for table_name in gb_db.table_names:
+        # Convert in memory db stop column to the old end name
+        _rename_column_if_exists(gb_db.db, table_name, "stop", "end")
+
+    gb_db.write(tmp_path / bad_col_path)
+
+    # Test that end column is renamed to stop on construction
+    loaded_gb_db = GenbankAnnotationDb(source=bad_col_path)
+
+    new_rich_dict = loaded_gb_db.to_rich_dict()
+    del new_rich_dict["init_args"]
+
+    assert new_rich_dict == correct_rich_dict
+
+
 def test_gbdb_get_children_fails_no_coords(gb_db):
     with pytest.raises(ValueError):
         _ = list(gb_db.get_feature_children(name="CNA00110"))
@@ -952,15 +1162,18 @@ def test_load_annotations_invalid_path():
         load_annotations(path="invalidfile.gff3")
 
 
-def test_subset_gff3_db(gff_db):
-    subset = gff_db.subset(seqid="I", start=40, end=70, allow_partial=True)
+@pytest.mark.parametrize("integer", (int, numpy.int64))
+def test_subset_gff3_db(gff_db, integer):
+    subset = gff_db.subset(
+        seqid="I", start=integer(40), stop=integer(70), allow_partial=True
+    )
     # manual inspection of the original GFF3 file indicates 7 records
     # BUT the CDS records get merged into a single row
     assert len(subset) == 6
 
 
 def test_subset_empty_db(gff_db):
-    subset = gff_db.subset(seqid="X", start=40, end=70, allow_partial=True)
+    subset = gff_db.subset(seqid="X", start=40, stop=70, allow_partial=True)
     # no records
     assert not len(subset)
 
@@ -970,7 +1183,7 @@ def test_subset_gff3_db_with_user(gff_db):
         seqid="I", name="gene-01", biotype="gene", spans=[(23, 43)], strand="+"
     )
     gff_db.add_feature(**record)
-    subset = gff_db.subset(seqid="I", start=40, end=70, allow_partial=True)
+    subset = gff_db.subset(seqid="I", start=40, stop=70, allow_partial=True)
     # manual inspection of the original GFF3 file indicates 7 records
     # BUT the CDS records get merged into a single row
     assert len(subset) == 7
@@ -985,7 +1198,7 @@ def test_subset_gb_db(gb_db):
 def test_subset_gff3_db_source(gff_db, tmp_dir):
     outpath = tmp_dir / "subset.gff3db"
     subset = gff_db.subset(
-        seqid="I", start=40, end=70, allow_partial=True, source=outpath
+        seqid="I", start=40, stop=70, allow_partial=True, source=outpath
     )
     subset.db.close()
 
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index 5330b2746..594ac9e22 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -1,5 +1,6 @@
 from unittest import TestCase
 
+import numpy
 import pytest
 
 from cogent3 import ASCII, DNA, get_moltype, make_aligned_seqs
@@ -135,7 +136,9 @@ def test_feature_residue():
     db.add_feature(seqid="x", biotype="exon", name="ex1", spans=[(0, 4)])
     aln_exons = list(aln.get_features(seqid="x", biotype="exon"))
     assert "biotype='exon', name='ex1', map=[0:1, 2:5]/10" in str(aln_exons)
-    assert aln_exons[0].get_slice().to_dict() == dict(x="CCCC", y="----")
+    exon = aln_exons[0]
+    exon_seq = exon.get_slice()
+    assert exon_seq.to_dict() == dict(x="CCCC", y="----")
     # Feature.as_one_span(), is applied to the exon that
     # straddles the gap in x. The result is we preserve that feature.
     exon_full_aln = aln_exons[0].as_one_span()
@@ -430,7 +433,7 @@ def test_feature_from_alignment():
 
     # But these will be returned as **alignment**
     # features with locations in alignment coordinates.
-    assert aln_exons.get_slice().to_dict() == {"x": "AAAAA", "y": "--TTT"}
+    assert aln[aln_exons].to_dict() == {"x": "AAAAA", "y": "--TTT"}
 
     # Similarly alignment features can be projected onto the aligned sequences,
     # where they may end up falling across gaps:
@@ -448,7 +451,7 @@ def test_nested_get_slice():
     s.add_feature(biotype="exon", name="trev", spans=[(30, 40)])
     s.add_feature(biotype="repeat", name="bob", spans=[(12, 17)], parent_id="fred")
     f = list(ex.get_children())[0]
-    assert str(f.get_slice()) == str(s[12:17])
+    assert str(s[f]) == str(s[12:17])
 
 
 def test_roundtrip_annotated_seq():
@@ -621,6 +624,21 @@ def test_feature_out_range():
     assert not f
 
 
+@pytest.mark.parametrize("cast", (list, numpy.array))
+def test_search_with_ints(cast):
+    """searching for features with numpy ints should work"""
+    start, stop = cast([2, 5])
+    seq = DNA.make_seq("AAAGGGGGAACCCT", name="x")
+    db = GffAnnotationDb()
+    db.add_feature(seqid="x", biotype="exon", name="E1", spans=[(3, 8)])
+    db.add_feature(seqid="x", biotype="exon", name="E2", spans=[(10, 13)])
+    seq.annotation_db = db
+    feats = list(
+        seq.get_features(biotype="exon", allow_partial=True, start=start, stop=stop)
+    )
+    assert len(feats) == 1
+
+
 def test_roundtripped_alignment_with_slices():
     """Sliced Alignment with annotations roundtrips correctly"""
     # annotations just on member sequences
@@ -632,9 +650,15 @@ def test_roundtripped_alignment_with_slices():
     db.add_feature(seqid="x", biotype="exon", name="E2", spans=[(10, 13)])
     aln.annotation_db = db
     # at the alignment level
+    sl = aln.seqs[0][:-3]
+    assert str(sl) == "-AAAGGGGGAACCCT"[:-3]
     sub_aln = aln[:-3]
+    feats = list(sub_aln.get_features(biotype="exon", allow_partial=True))
+    assert len(feats) == 2
     new = deserialise_object(sub_aln.to_json())
-    gf1, gf2 = list(new.get_features(biotype="exon", allow_partial=True))
+    feats = list(new.get_features(biotype="exon", allow_partial=True))
+    assert len(feats) == 2
+    gf1, gf2 = feats
     assert gf1.get_slice().to_dict() == {"x": "GGGGG", "y": "--TTT"}
     assert gf2.get_slice().to_dict() == {"x": "C", "y": "G"}
 
@@ -693,7 +717,12 @@ def test_feature_not_equal(ann_seq):
 def test_feature_not_equal_attr(ann_seq, attr):
     (f1,) = list(ann_seq.get_features(biotype="gene"))
     attrs = dict(
-        parent=f1.parent, seqid=f1.seqid, biotype=f1.biotype, map=f1.map, name=f1.name
+        parent=f1.parent,
+        seqid=f1.seqid,
+        biotype=f1.biotype,
+        map=f1.map,
+        name=f1.name,
+        strand="-" if f1.reversed else "+",
     )
     value = attrs["map"][:4] if attr == "map" else "different"
     attrs[attr] = value
diff --git a/tests/test_core/test_location.py b/tests/test_core/test_location.py
index 6c940da11..4b2a0d0a0 100644
--- a/tests/test_core/test_location.py
+++ b/tests/test_core/test_location.py
@@ -1,9 +1,21 @@
 """Unit tests for Span classes.
 """
+
+from itertools import combinations
 from unittest import TestCase
 
-from cogent3 import DNA
-from cogent3.core.location import Map, Span, gap_coords_to_map
+import numpy
+import pytest
+
+from cogent3 import DNA, make_seq
+from cogent3.core.location import (
+    FeatureMap,
+    IndelMap,
+    LostSpan,
+    Span,
+    TerminalPadding,
+    gap_coords_to_map,
+)
 
 
 class SpanTests(TestCase):
@@ -254,51 +266,12 @@ def test_ends_inside(self):
 class MapTests(TestCase):
     """tests of the Map class"""
 
-    def test_get_coords(self):
-        """get_coordinates should return raw coordinates matching input"""
-        spans = [(0, 9), (20, 32)]
-        map = Map(spans, parent_length=100)
-        coords = map.get_coordinates()
-        self.assertEqual(coords, spans)
-
-        # should work for reversed Maps too
-        spans = [(32, 20), (9, 0)]
-        map = Map(spans, parent_length=100)
-        coords = map.get_coordinates()
-        self.assertEqual(coords, spans)
-
     def test_get_gap_coords(self):
         """returns gap start and lengths"""
-        m, seq = DNA.make_seq("-AC--GT-TTA--").parse_out_gaps()
+        m, _ = DNA.make_seq("-AC--GT-TTA--").parse_out_gaps()
         got = m.get_gap_coordinates()
         self.assertEqual(dict(got), {0: 1, 2: 2, 4: 1, 7: 2})
 
-    def test_gap_coords_to_map(self):
-        """construct a Map from coordinates of gap alone"""
-        m, seq = DNA.make_seq("-AC--GT-TTA--").parse_out_gaps()
-        gap_coords = {0: 1, 2: 2, 4: 1, 7: 2}
-        seqlen = 70
-        got = gap_coords_to_map(gap_coords, seqlen)
-        self.assertEqual(len(got), seqlen + sum(gap_coords.values()))
-
-        gap_coords = {5: 2, 17: 3, 10: 2}
-        seqlen = 20
-        got = gap_coords_to_map(gap_coords, seqlen)
-        self.assertEqual(len(got), sum(gap_coords.values()) + seqlen)
-
-        # roundtrip from Map.get_gap_coordinates()
-        self.assertEqual(dict(got.get_gap_coordinates()), gap_coords)
-
-        # and no gaps
-        m, seq = DNA.make_seq("ACGTTTA").parse_out_gaps()
-        got = gap_coords_to_map({}, len(seq))
-        self.assertEqual(len(got), len(m))
-        self.assertEqual(got.get_coordinates(), m.get_coordinates())
-
-        # and gaps outside sequence
-        with self.assertRaises(ValueError):
-            got = gap_coords_to_map({20: 1}, len(seq))
-
 
 def test_map_plus_position():
     # seq is 9 long
@@ -306,8 +279,7 @@ def test_map_plus_position():
     # +slice         **
     # plus seq     AAACCCTGG
 
-    # orig = Aligned(*DNA.make_seq("AAACCCTGG", name="a").parse_out_gaps())
-    orig = Map([(0, 9)], parent_length=9)
+    orig = FeatureMap.from_locations(locations=[(0, 9)], parent_length=9)
     assert orig.absolute_position(2) == 2
     assert orig.absolute_position(6) == 6
 
@@ -329,3 +301,899 @@ def test_map_plus_position():
     rc = orig.nucleic_reversed()
     rcsubseq = rc[2:7]
     abs_coord = rcsubseq.absolute_position(0)
+
+
+def test_indel_map_useful_complete():
+    im = IndelMap.from_spans(spans=[LostSpan(3)], parent_length=0)
+    assert not im.useful
+    assert not im.complete
+    assert len(im) == 3
+
+
+def test_map_nucleic_reversed():
+    expect = [(0, 9)]
+    # seq is 9 long
+    # plus coords  012345678
+    # plus seq     AAACCCTGG
+
+    orig = FeatureMap.from_locations(locations=[(0, 9)], parent_length=9)
+    # minus coords 012345678
+    # rel coord      01234
+    # -slice         *****
+    # minus seq    CCAGGGTTT
+    # plus coords  876543210
+    rc = orig.nucleic_reversed()
+    coords = rc.get_coordinates()
+    assert coords == expect
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_coordinate(cls):
+    # coordinates are for ungapped segments in underlying sequence
+    #                   01   2 345
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
+    got = m.get_coordinates()
+    assert got == [(0, 2), (2, 3), (3, 6)]
+
+
+@pytest.mark.parametrize("cls", (FeatureMap, IndelMap))
+def test_gap_coordinate(cls):
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
+    got = m.get_gap_coordinates()
+    assert [tuple(c) for c in got] == [(2, 3), (3, 1), (6, 2)]
+
+
+def test_gaps():
+    # returns spans corresponding to position on "aligned" seq of gaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = FeatureMap.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
+    got = [(g.start, g.end) for g in tuple(m.gaps().spans)]
+    assert got == [(2, 5), (6, 7), (10, 12)]
+
+
+@pytest.mark.parametrize("cls", (IndelMap, FeatureMap))
+def test_nongap(cls):
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("AC---G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
+
+    got = [(s.start, s.end) for s in m.nongap()]
+    assert got == [(0, 2), (5, 6), (7, 10)]
+
+
+@pytest.mark.parametrize("cls", (IndelMap, FeatureMap))
+def test_nongap_startswith(cls):
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   012345678
+    seq = DNA.make_seq("--G-TAA--")
+    m, _ = seq.parse_out_gaps()
+    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
+
+    got = [(s.start, s.end) for s in m.nongap()]
+    assert got == [(2, 3), (4, 7)]
+
+
+@pytest.mark.parametrize("cls", (IndelMap, FeatureMap))
+def test_nongap_not_endswith(cls):
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   0123456
+    seq = DNA.make_seq("--G-TAA")
+    m, _ = seq.parse_out_gaps()
+    m = cls.from_spans(spans=tuple(m.spans), parent_length=m.parent_length)
+
+    got = [(s.start, s.end) for s in m.nongap()]
+    assert got == [(2, 3), (4, 7)]
+
+
+def test_spans_gen():
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("AC---G-TAA--")
+    expect = [Span(0, 2), LostSpan(3), Span(2, 3), LostSpan(1), Span(3, 6), LostSpan(2)]
+    m, _ = seq.parse_out_gaps()
+    gap_data = numpy.array([(2, 3), (3, 1), (6, 2)])
+    pos, lengths = gap_data.T
+    im = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=m.parent_length)
+    got = list(im.spans)
+    assert got == expect
+
+
+def test_spans_gap_start():
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("---TAA")
+    expect = [LostSpan(3), Span(0, 3)]
+    im, _ = seq.parse_out_gaps()
+    got = list(im.spans)
+    assert got == expect
+
+
+def test_spans_gen_nogaps():
+    # returns spans corresponding to position on "aligned" seq of nongaps
+    #                   000000000011
+    #                   012345678901
+    seq = DNA.make_seq("ACGTAA")
+    m, _ = seq.parse_out_gaps()
+    spans = list(m.spans)
+    assert len(spans) == 1
+    assert len(spans[0]) == len(seq)
+
+
+def test_round_trip_rich_dict():
+    seq = DNA.make_seq("AC---G-TAA--")
+    im, _ = seq.parse_out_gaps()
+    rd = im.to_rich_dict()
+    got = IndelMap.from_rich_dict(rd)
+    assert im is not got
+    assert got.to_rich_dict() == im.to_rich_dict()
+
+
+def test_terminal_unknown():
+    # seq  idx   01   2 345  6
+    #           -AC---G-TAA--
+    # aligned seq length is 13
+    gap_data = numpy.array([[0, 1], [2, 3], [3, 1], [6, 2]])
+    gap_pos, gap_lengths = gap_data.T
+
+    m = IndelMap(
+        gap_pos=gap_pos.copy(), gap_lengths=gap_lengths.copy(), parent_length=6
+    )
+    # not unknown, by default
+    m_spans = tuple(m.spans)
+    assert m_spans[0].lost and not isinstance(m_spans[0], TerminalPadding)
+    # use the constructor arg
+    m = IndelMap(
+        gap_pos=gap_pos.copy(),
+        gap_lengths=gap_lengths.copy(),
+        parent_length=6,
+        termini_unknown=True,
+    )
+    m_spans = tuple(m.spans)
+    assert isinstance(m_spans[0], TerminalPadding)
+    assert isinstance(m_spans[-1], TerminalPadding)
+    assert m_spans[2].lost and not isinstance(m_spans[1], TerminalPadding)
+    assert m_spans[4].lost and not isinstance(m_spans[2], TerminalPadding)
+
+    # use the method
+    m = IndelMap(
+        gap_pos=gap_pos.copy(), gap_lengths=gap_lengths.copy(), parent_length=6
+    ).with_termini_unknown()
+    m_spans = tuple(m.spans)
+    assert isinstance(m_spans[0], TerminalPadding)
+    assert isinstance(m_spans[-1], TerminalPadding)
+    # middle gap is not terminal, so...
+    assert not isinstance(m_spans[2], TerminalPadding)
+
+    # no gaps, no effect
+    # use the constructor arg
+    empty = numpy.array([], dtype=int)
+    m = IndelMap(
+        gap_pos=empty.copy(),
+        gap_lengths=empty.copy(),
+        parent_length=6,
+        termini_unknown=True,
+    )
+    m_spans = tuple(m.spans)
+    assert len(m_spans) == 1 and not m_spans[0].lost
+    # use the method
+    m = IndelMap(
+        gap_pos=empty.copy(), gap_lengths=empty.copy(), parent_length=6
+    ).with_termini_unknown()
+    m_spans = tuple(m.spans)
+    assert len(m_spans) == 1 and not m_spans[0].lost
+
+
+def test_featuremap_inverse():
+    m = FeatureMap.from_locations(locations=[(0, 2), (4, 6)], parent_length=6)
+    assert len(m) == 4
+    mi = m.inverse()
+    assert len(mi) == 6
+    mi_spans = tuple(mi.spans)
+    assert mi_spans[1].lost and len(mi_spans[1]) == 2
+    # invert the inversion, should give us back the original
+    re_inv = mi.inverse()
+    expect = m.to_rich_dict()
+    got = re_inv.to_rich_dict()
+    assert got == expect
+
+
+def test_indelmap_from_aligned_segments():
+    locations = [(0, 2), (4, 6)]
+    im = IndelMap.from_aligned_segments(locations=locations, aligned_length=6)
+    assert len(im) == 6
+    expected_length = sum(e - s for s, e in locations)
+    assert im.parent_length == expected_length
+    im_spans = tuple(im.spans)
+    assert im_spans[1].lost and len(im_spans[1]) == 2
+
+
+@pytest.mark.parametrize("swap", (False, True))
+def test_indelmap_inconsistent_input(swap):
+    a = numpy.array([0, 1], dtype=int)
+    b = numpy.array([3], dtype=int)
+    a, b = (b, a) if swap else (a, b)
+    with pytest.raises(ValueError):
+        IndelMap(gap_pos=a, gap_lengths=b, parent_length=10)
+
+    with pytest.raises(ValueError):
+        IndelMap(gap_pos=a, cum_gap_lengths=b, parent_length=10)
+
+
+def test_indelmap_from_aligned_segments2():
+    locations = [(0, 5), (7, 12), (14, 21), (24, 27)]
+    im = IndelMap.from_aligned_segments(locations=locations, aligned_length=27)
+    expected_length = sum(e - s for s, e in locations)
+    assert im.parent_length == expected_length
+
+
+def test_indelmap_merge():
+    im1 = IndelMap(
+        gap_pos=numpy.array([], dtype=int),
+        gap_lengths=numpy.array([], dtype=int),
+        parent_length=4,
+    )
+    im2 = IndelMap(
+        gap_pos=numpy.array([0], dtype=int),
+        gap_lengths=numpy.array([2], dtype=int),
+        parent_length=4,
+    )
+
+    assert len(im1) == 4
+    assert im1.parent_length == 4
+    inv = im1.merge_maps(im2)
+    assert inv.parent_length == 4
+    assert len(inv) == 6
+
+    fmap1 = im1.to_feature_map()
+    fmap2 = im2.to_feature_map()
+    got = fmap2.inverse()[fmap1.inverse()].inverse()
+    assert inv.get_gap_coordinates() == [
+        list(coords) for coords in got.get_gap_coordinates()
+    ]
+
+
+def test_indelmap_merge_parent_length():
+    im1 = IndelMap(
+        gap_pos=numpy.array([], dtype=int),
+        gap_lengths=numpy.array([], dtype=int),
+        parent_length=4,
+    )
+    im2 = IndelMap(
+        gap_pos=numpy.array([0], dtype=int),
+        gap_lengths=numpy.array([2], dtype=int),
+        parent_length=4,
+    )
+    # providing a value for parent_length overrides standard
+    ov = im1.merge_maps(im2, parent_length=20)
+    assert ov.parent_length != im2.parent_length
+    assert ov.parent_length == 20
+
+
+def test_map_indexed():
+    m = FeatureMap.from_locations(locations=[(0, 2), (4, 6)], parent_length=6).inverse()
+    indexed = m[2]
+    assert len(indexed) == 1
+
+
+def test_compare_map_indexed():
+    from cogent3.core.alignment import Aligned
+
+    raw_seq = "--AC-GTAA--"
+    im, seq = DNA.make_seq(raw_seq).parse_out_gaps()
+    ia = Aligned(im, seq)
+    length = len(raw_seq)
+    got = [str(ia[i]) for i in range(length)]
+    expect = list("--AC-GTAA--")
+    assert got == expect
+
+
+@pytest.mark.parametrize("slice_it", (True, False))
+def test_feature_map_zeroed(slice_it):
+    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
+    kwargs = dict(spans=spans, parent_length=6)
+
+    fm = FeatureMap(**kwargs)
+    if slice_it:
+        fm = fm[3:6]
+
+    fm_zeroed = fm.zeroed()
+    assert fm.start > 0
+    assert fm_zeroed.start == 0
+
+
+def test_indelmap_to_feature_map():
+    spans = [LostSpan(2), Span(2, 4), LostSpan(2), Span(4, 8), LostSpan(2)]
+    kwargs = dict(spans=spans, parent_length=8)
+    im = IndelMap.from_spans(**kwargs)
+    mm = im.to_feature_map()
+    assert mm.get_coordinates() == im.get_coordinates()
+
+
+@pytest.mark.parametrize("raw", ("--AC--GGGG--", "A-A-A", "-A-AA----A"))
+def test_indelmap_nucleic_reversed(raw):
+    from cogent3.core.alignment import Aligned
+
+    plus = DNA.make_seq(raw)
+    minus = plus.rc()
+    plus_imap, _ = DNA.make_seq(raw).parse_out_gaps()
+    minus_imap, minus_seq = minus.parse_out_gaps()
+    got = plus_imap.nucleic_reversed()
+    assert got.get_coordinates() == minus_imap.get_coordinates()
+    assert (got.gap_pos == minus_imap.gap_pos).all()
+    assert (got.cum_gap_lengths == minus_imap.cum_gap_lengths).all()
+    assert got.parent_length == minus_imap.parent_length
+    assert str(Aligned(got, minus_seq)) == str(minus)
+
+
+def test_get_coords():
+    """get_coordinates should return raw coordinates matching input"""
+    spans = [(0, 9), (20, 32)]
+    fmap = FeatureMap.from_locations(locations=spans, parent_length=100)
+    coords = fmap.get_coordinates()
+    assert coords == spans
+
+
+def test_indel_map_get_coords():
+    """get_coordinates should return raw coordinates matching input"""
+    imap = IndelMap(
+        gap_pos=numpy.array([9]), gap_lengths=numpy.array([10]), parent_length=20
+    )
+    locations = [(0, 9), (9, 20)]
+    coords = imap.get_coordinates()
+    assert coords == locations
+
+
+def test_indel_map_get_gap_coords():
+    gap_data = numpy.array([(2, 3), (3, 1), (6, 2)])
+    gap_pos, lengths = gap_data[:, 0], gap_data[:, 1]
+    m = IndelMap(gap_pos=gap_pos, gap_lengths=lengths, parent_length=10)
+    got = m.get_gap_coordinates()
+    assert got == gap_data.tolist()
+
+
+@pytest.mark.parametrize(
+    "coords", ([(0, 3), (7, 11)], [(0, 3)], [(2, 4), (6, 10)], [(4, 6)])
+)
+def test_indelmap_joined_segments(coords):
+    raw = "--AC--GGGG--"
+    expect, _ = DNA.make_seq("".join(raw[s:e] for s, e in coords)).parse_out_gaps()
+    imap, _ = DNA.make_seq(raw).parse_out_gaps()
+    got = imap.joined_segments(coords)
+    assert got.gap_pos.tolist() == expect.gap_pos.tolist()
+    assert got.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()
+
+
+def test_indelmap_slice_terminating():
+    raw = "-CB-A--"
+    start, end = 4, 6
+    expect, _ = DNA.make_seq(raw[start:end]).parse_out_gaps()
+    imap, _ = DNA.make_seq(raw).parse_out_gaps()
+    got = imap[start:end]
+    assert got.gap_pos.tolist() == expect.gap_pos.tolist()
+    assert got.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()
+
+
+def test_indelmap_slice_zero():
+    raw = "-CB-A--"
+    start, end = 0, 0
+    expect, s = DNA.make_seq(raw[start:end]).parse_out_gaps()
+    imap, _ = DNA.make_seq(raw).parse_out_gaps()
+    got = imap[start:end]
+    assert got.parent_length == len(s)
+    assert got.gap_pos.tolist() == expect.gap_pos.tolist()
+    assert got.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()
+
+
+def test_indelmap_invalid_slice_range():
+    imap = IndelMap(
+        gap_pos=numpy.array([10], dtype=int),
+        gap_lengths=numpy.array([2], dtype=int),
+        parent_length=10,
+    )
+    with pytest.raises(IndexError):
+        imap[-100]
+
+    with pytest.raises(IndexError):
+        imap[-100:]
+
+    with pytest.raises(IndexError):
+        imap[:-99]
+
+
+def test_indelmap_get_indices_errors():
+    imap = IndelMap(
+        gap_pos=numpy.array([10], dtype=int),
+        gap_lengths=numpy.array([2], dtype=int),
+        parent_length=10,
+    )
+    with pytest.raises(IndexError):
+        imap.get_align_index(-1000)
+
+
+def test_indelmap_slice_innner_gap():
+    start, end = 4, 6
+    raw = "--AC--GGGG--"
+    expect, _ = DNA.make_seq(raw[start:end]).parse_out_gaps()
+    imap, _ = DNA.make_seq(raw).parse_out_gaps()
+    imap = imap[start:end]
+    assert imap.gap_pos.tolist() == expect.gap_pos.tolist()
+
+
+def test_indelmap_slice_cum_length():
+    start, end = 7, 11
+    raw = "--AC--GGGG--"
+    expect, _ = DNA.make_seq(raw[start:end]).parse_out_gaps()
+    imap, _ = DNA.make_seq(raw).parse_out_gaps()
+    imap = imap[start:end]
+    assert imap.gap_pos.tolist() == expect.gap_pos.tolist()
+    assert imap.cum_gap_lengths.tolist() == expect.cum_gap_lengths.tolist()
+
+
+def test_get_coords_invalid_order():
+    """get_coordinates should return raw coordinates matching input"""
+
+    # should work for reversed Maps too
+    spans = [(32, 20), (9, 0)]
+    with pytest.raises(ValueError):
+        FeatureMap.from_locations(locations=spans, parent_length=100)
+
+
+def test_indelmap_slice_gap_into_seq():
+    pos, lengths = numpy.array([[3, 1], [7, 1]]).T
+    gaps = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=7)
+    # example seq
+    # 012345678
+    # 012 3456
+    # ACA-CGAC-
+    # slicing from aligned coords 3-5, gives '-C'
+    got = gaps[3:5]
+    assert got.gap_pos.tolist() == [0]
+    assert got.cum_gap_lengths.tolist() == [1]
+
+
+def test_indelmap_slice_one_gap():
+    pos, lengths = numpy.array([[3, 6]]).T
+    gaps = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=24)
+    # example seq
+    #           1
+    # 01234567890
+    # 012      3456789
+    # GTG------GTAGAAGTTCCAAATAATGAA
+    # slicing from aligned coords 3-5, gives 'TG------G'
+    # in sliced seq, gap at 2
+    got = gaps[1:10]
+    assert got.gap_pos.tolist() == [2]
+    assert got.cum_gap_lengths.tolist() == [6]
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        "AB---CD--EF",
+        "---ABCD--EF",
+        "ABCD---EF--",
+        "-----ABCDEF",
+        "ABCDEF-----",
+        "-ABCDEF----",
+        "-A-B-C-D-EF",
+        "A-B-C-D-EF-",
+    ),
+)
+@pytest.mark.parametrize("index", range(4, 6))  # the ungapped sequence is 6 long
+def test_gapped_convert_seq2aln(data, index):
+    # converting a sequence index to alignment index
+    ungapped = data.replace("-", "")
+    seq = make_seq(data, moltype="text")
+    gaps, _ = seq.parse_out_gaps()
+    idx = gaps.get_align_index(index)
+    assert data[idx] == ungapped[index]
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        "AB---CD--EF",
+        "---ABCD--EF",
+        "ABCD---EF--",
+        "-----ABCDEF",
+        "ABCDEF-----",
+        "-ABCDEF----",
+        "-A-B-C-D-EF",
+        "A-B-C-D-EF-",
+    ),
+)
+@pytest.mark.parametrize(
+    "start,end", list(combinations(range(6), 2))
+)  # the ungapped sequence is 6 long
+def test_indelmap_align_index_slice_stop(data, start, end):
+    # converting a sequence index to alignment index
+    ungapped = data.replace("-", "")
+    seq = make_seq(data, moltype="text")
+    gaps, _ = seq.parse_out_gaps()
+    stop = gaps.get_align_index(end, slice_stop=True)
+    assert data[stop - 1] == ungapped[end - 1]
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        "AB---CD--EF",
+        "---ABCD--EF",
+        "ABCD---EF--",
+        "-----ABCDEF",
+        "ABCDEF-----",
+        "-ABCDEF----",
+        "-A-B-C-D-EF",
+        "A-B-C-D-EF-",
+    ),
+)
+@pytest.mark.parametrize("index", range(6))  # the ungapped sequence is 6 long
+def test_gapped_convert_seq2aln2seq(data, index):
+    # round tripping seq to alignment to seq indices
+    gaps, _ = make_seq(data, moltype="text").parse_out_gaps()
+    align_index = gaps.get_align_index(index)
+    got = gaps.get_seq_index(align_index)
+    assert got == index
+
+
+@pytest.mark.parametrize("expect", range(10))
+def test_indelmap_get_seq_index_negative(expect):
+    parent_length = 10
+    gap_pos = [0]
+    gap_lengths = [3]
+    imap = IndelMap(
+        gap_pos=numpy.array(gap_pos),
+        gap_lengths=numpy.array(gap_lengths),
+        parent_length=parent_length,
+    )
+    neg_index = expect - parent_length
+    got = imap.get_seq_index(neg_index)
+    assert got == expect
+
+
+@pytest.mark.parametrize("expect", range(10))
+def test_indelmap_get_align_index_negative(expect):
+    parent_length = 10
+    gap_pos = [0]
+    gap_lengths = [3]
+    gap_length = gap_lengths[0]
+    imap = IndelMap(
+        gap_pos=numpy.array(gap_pos),
+        gap_lengths=numpy.array(gap_lengths),
+        parent_length=parent_length,
+    )
+    neg_index = expect + gap_length - len(imap)
+    got = imap.get_seq_index(neg_index)
+    assert got == expect
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        "AB--CDE-FG",
+        "--ABC-DEFG",
+        "AB--CDE-FG--",
+        "ABCDE--FG---",
+        "-----ABCDEFG",
+        "-A-B-C-D-E-F-G-",
+    ),
+)
+@pytest.mark.parametrize("seq_index", range(7))
+def test_gapped_convert_aln2seq_nongap_char(data, seq_index):
+    # test alignment indexes when aligned position is NOT a gap
+    ungapped = "ABCDEFG"
+    align_index = data.find(ungapped[seq_index])
+    gaps, _ = make_seq(data, moltype="text").parse_out_gaps()
+    idx = gaps.get_seq_index(align_index)
+    assert idx == seq_index
+
+
+def _find_nth_gap_index(data: str, n: int) -> int:
+    num = -1
+    for i, c in enumerate(data):
+        if c == "-":
+            num += 1
+        if num == n:
+            return i
+    raise ValueError(f"{data=}, {n=}")
+
+
+def _get_expected_seqindex(data: str, align_index: int) -> int:
+    # compute the expected seqindex
+    refseq = data.replace("-", "")
+    got = data[align_index:].lstrip("-")
+    return refseq.find(got[0]) if got else len(refseq)
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        "AB-----CDE-F--G",
+        "----ABC-DEFG---",
+        "AB--CDE-FG-----",
+        "ABCDE--FG------",
+        "--------ABCDEFG",
+        "-A-B-C-D-E-F-G-",
+    ),
+)
+@pytest.mark.parametrize("gap_number", range(8))
+def test_gapped_convert_aln2seq_gapchar(data, gap_number):
+    # test alignment indexes when aligned position IS a gap
+    # in this case we expect the position of the next non-gap
+    # to be the result
+    # find the alignment index corresponding to the
+    align_index = _find_nth_gap_index(data, gap_number)
+    assert data[align_index] == "-", (data, gap_number)
+    # find nearest non-gap
+    seq_index = _get_expected_seqindex(data, align_index)
+    gaps, _ = make_seq(data, moltype="text").parse_out_gaps()
+    idx = gaps.get_seq_index(align_index)
+    assert idx == seq_index
+
+
+def test_gapped_convert_aln2seq_invalid():
+    gaps, _ = make_seq("AC--GTA-TG", moltype="dna").parse_out_gaps()
+    with pytest.raises(IndexError):
+        # absolute value of negative indices must be < seq length
+        gaps.get_seq_index(-100)
+
+
+@pytest.mark.parametrize(
+    "invalid_slice",
+    (slice(None, None, -1), slice(None, None, 2)),
+)
+def test_gap_pos_invalid_slice(invalid_slice):
+    pos, lengths = numpy.array([[1, 3]], dtype=numpy.int32).T
+    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=20)
+    with pytest.raises(NotImplementedError):
+        _ = gp[invalid_slice]
+
+
+@pytest.mark.parametrize(
+    "aslice",
+    (
+        slice(3, 7),
+        slice(20, None),
+    ),
+)
+def test_no_gaps_in_slice(aslice):
+    # aligned length is 25
+    seq_length = 20
+    gap_length = 5
+    pos, lengths = numpy.array([[10, gap_length]], dtype=numpy.int32).T
+    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=seq_length)
+    got = gp[aslice]
+    assert not got.num_gaps
+    start = aslice.start or 0
+    stop = aslice.stop or (seq_length + gap_length)
+    assert len(got) == stop - start
+
+
+def test_len_gapped():
+    seq_length = 20
+    gap_length = 5
+    pos, lengths = numpy.array([[10, gap_length]], dtype=numpy.int32).T
+    gp = IndelMap(gap_pos=pos, gap_lengths=lengths, parent_length=seq_length)
+    assert len(gp) == (seq_length + gap_length)
+
+
+def test_all_gaps_in_slice():
+    # slicing GapPositions
+    # sample seq 1
+    data = "AC--GTA-TG"
+    gp, _ = make_seq(data, moltype="dna").parse_out_gaps()
+    sl = slice(1, 9)
+
+    got = gp[sl]
+    expect_gaps, _ = make_seq(data[sl], moltype="dna").parse_out_gaps()
+    assert got.get_gap_coordinates() == expect_gaps.get_gap_coordinates()
+    assert got.parent_length == 5
+
+
+@pytest.mark.parametrize(
+    "data",
+    (
+        "----GTA-TG",
+        "AC--GTA---",
+        "AC--GTA-TG",
+        "A-C-G-T-A-",
+        "-A-C-G-T-A",
+        "ACGTAACGTA",
+        "----------",
+    ),
+)
+@pytest.mark.parametrize(
+    "aslice",
+    [slice(i, j) for i, j in combinations(range(10), 2)],
+)
+def test_variant_slices(data, aslice):
+    gaps, _ = make_seq(data, moltype="dna").parse_out_gaps()
+    got = gaps[aslice]
+
+    expect_gaps, expect_seq = make_seq(data[aslice], moltype="dna").parse_out_gaps()
+    assert got.parent_length == len(expect_seq)
+    assert (got.gap_pos == expect_gaps.gap_pos).all()
+    assert (got.cum_gap_lengths == expect_gaps.cum_gap_lengths).all()
+
+
+def test_indelmap_joined():
+    pos = numpy.array([0, 1])
+    cum_len = numpy.array([1, 5])
+    imap = IndelMap(gap_pos=pos, cum_gap_lengths=cum_len, parent_length=5)
+    fmap = FeatureMap.from_locations(locations=[(0, 1), (2, 5)], parent_length=10)
+    coords = fmap.get_coordinates()
+    got = imap.joined_segments(coords)
+    assert got.num_gaps == 1
+    assert got.gap_pos[0] == 0
+    assert got.cum_gap_lengths[0] == (1 + 5 - 2)
+
+
+def test_indel_map_sliced_with_negative():
+    imap = IndelMap(
+        gap_pos=numpy.array([0]), cum_gap_lengths=numpy.array([1]), parent_length=14
+    )
+    got = imap[:-3]
+    assert got.parent_length == 14 - 3
+
+
+def test_indelmap_roundtrip_json():
+    from cogent3.util.deserialise import deserialise_object
+
+    imap = IndelMap(
+        gap_pos=numpy.array([0, 9]),
+        cum_gap_lengths=numpy.array([1, 3]),
+        parent_length=14,
+    )
+    got = deserialise_object(imap.to_json())
+    assert (got.gap_pos == imap.gap_pos).all()
+    assert (got.cum_gap_lengths == imap.cum_gap_lengths).all()
+    assert got.parent_length == imap.parent_length
+
+
+def test_featuremap_roundtrip_json():
+    from cogent3.util.deserialise import deserialise_object
+
+    fmap = FeatureMap.from_locations(
+        locations=[[0, 9], [20, 23]],
+        parent_length=140,
+    )
+    got = deserialise_object(fmap.to_json())
+    coords = fmap.get_coordinates()
+    assert coords == [(0, 9), (20, 23)]
+    assert got.parent_length == fmap.parent_length == 140
+
+
+@pytest.mark.parametrize(
+    "error_type,locations", ((ValueError, ((-2, 2),)), (RuntimeError, ((20, 25),)))
+)
+def test_invalid_locations(error_type, locations):
+    with pytest.raises(error_type):
+        FeatureMap.from_locations(locations=locations, parent_length=10)
+
+
+def test_gap_coords_to_map():
+    """construct a Map from coordinates of gap alone"""
+    gap_coords = {0: 1, 2: 2, 4: 1, 7: 2}
+    seqlen = 70
+    got = gap_coords_to_map(gap_coords, seqlen)
+    assert len(got) == seqlen + sum(gap_coords.values())
+
+    gap_coords = {5: 2, 17: 3, 10: 2}
+    seqlen = 20
+    got = gap_coords_to_map(gap_coords, seqlen)
+    assert len(got) == sum(gap_coords.values()) + seqlen
+
+    # roundtrip from Map.get_gap_coordinates()
+    assert dict(got.get_gap_coordinates()) == gap_coords
+
+    # and no gaps
+    m, seq = DNA.make_seq("ACGTTTA").parse_out_gaps()
+    got = gap_coords_to_map({}, len(seq))
+    assert len(got) == len(m)
+    assert got.get_coordinates() == m.get_coordinates()
+
+    # and gaps outside sequence
+    with pytest.raises(ValueError):
+        gap_coords_to_map({20: 1}, len(seq))
+
+
+@pytest.mark.parametrize(
+    "seq, expected",
+    [
+        ("ACGTACGT", []),
+        ("----ACGTACGT----", [(0, 4), (12, 16)]),
+        ("ACGT----ACGT", [4, 8]),
+        ("----", [[0, 4]]),
+        ("----ACGT", [[0, 4]]),
+        ("ACGTACGT----", [(8, 12)]),
+    ],
+)
+def test_get_gap_align_coordinates1(seq, expected):
+    expected = numpy.array(expected, dtype=int)
+    if not len(expected):
+        expected = expected.reshape((0, 2))
+    im, _ = make_seq(seq).parse_out_gaps()
+    result = im.get_gap_align_coordinates()
+    assert (result == expected).all(), f"{expected=}, {result=}"
+
+
+@pytest.mark.parametrize(
+    "seq, expected",
+    [
+        ("", []),
+        ("A", []),
+        ("-", [[0, 1]]),
+    ],
+)
+def test_get_gap_align_coordinates_edge_cases(seq, expected):
+    expected = numpy.array(expected, dtype=int)
+    if not len(expected):
+        expected = expected.reshape((0, 2))
+
+    im, _ = make_seq(seq).parse_out_gaps()
+    result = im.get_gap_align_coordinates()
+    assert (result == expected).all(), f"{expected=}, {result=}"
+
+
+def test_featuremap_add():
+    spans_a = [LostSpan(2), Span(2, 4)]
+    spans_b = [LostSpan(2), Span(4, 8), LostSpan(2)]
+    kwargs = dict(parent_length=6)
+
+    fm_a = FeatureMap(spans=spans_a, **kwargs)
+    fm_b = FeatureMap(spans=spans_b, **kwargs)
+    fm_ab = fm_a + fm_b
+    assert list(fm_ab.spans) == (spans_a + spans_b)
+
+
+def test_featuremap_mul():
+    spans = [LostSpan(2), Span(2, 4)]
+    fm = FeatureMap(spans=spans, parent_length=6)
+    fm_3 = fm * 3
+    assert list(fm_3.spans) == [sp * 3 for sp in spans]
+    assert fm_3.parent_length == 6 * 3
+
+
+def test_featuremap_div():
+    spans = [LostSpan(3), Span(3, 6)]
+    fm_3 = FeatureMap(spans=spans, parent_length=6)
+    fm_1 = fm_3 / 3
+    assert list(fm_1.spans) == [sp / 3 for sp in spans]
+    assert fm_1.parent_length == 6 / 3
+
+
+def test_indelmap_make_seq_feature_map():
+    #           1
+    # 01234567890
+    # AC--GTA-TAA
+    # 01    234 567
+    im = IndelMap(
+        gap_pos=numpy.array([2, 5], dtype=int),
+        gap_lengths=numpy.array([2, 1], dtype=int),
+        parent_length=8,
+    )
+    orig_spans = [Span(1, 5)]
+    align_map = FeatureMap(spans=orig_spans, parent_length=11)
+    spans = [Span(1, 3)]
+    expect = FeatureMap(spans=spans, parent_length=8)
+    got = im.make_seq_feature_map(align_map)
+    assert got.get_coordinates() == expect.get_coordinates()
+    assert got.parent_length == expect.parent_length
+
+    # ignoring lost spans
+    align_map = FeatureMap(spans=orig_spans + [LostSpan(4)], parent_length=11)
+    got = im.make_seq_feature_map(align_map)
+    assert got.get_coordinates() == expect.get_coordinates()
+    assert got.parent_length == expect.parent_length
diff --git a/tests/test_core/test_maps.py b/tests/test_core/test_maps.py
index 6ccb21b31..5eca67498 100644
--- a/tests/test_core/test_maps.py
+++ b/tests/test_core/test_maps.py
@@ -1,7 +1,7 @@
 import unittest
 
 from cogent3 import DNA, make_aligned_seqs
-from cogent3.core.location import Map, Span
+from cogent3.core.location import FeatureMap, Span
 
 
 class MapTest(unittest.TestCase):
@@ -9,7 +9,7 @@ class MapTest(unittest.TestCase):
 
     def test_spans(self):
         # a simple two part map of length 10
-        map = Map([(0, 5), (5, 10)], parent_length=10)
+        map = FeatureMap.from_locations(locations=[(0, 5), (5, 10)], parent_length=10)
         # try different spans on the above map
         for (start, end), expected in [
             ((0, 4), "[0:4]"),
diff --git a/tests/test_core/test_moltype.py b/tests/test_core/test_moltype.py
index 382df337e..7e59054c8 100644
--- a/tests/test_core/test_moltype.py
+++ b/tests/test_core/test_moltype.py
@@ -1139,3 +1139,16 @@ def test_resolve_ambiguity_codons():
         DNA.resolve_ambiguity("at-")
     with pytest.raises(AlphabetError):
         DNA.resolve_ambiguity("---", alphabet=codon_alpha)
+
+
+def test_make_seq_on_seq():
+    seq = DNA.make_seq("ACGG")
+    got = DNA.make_seq(seq)
+    assert got is seq
+
+
+def test_make_seq_diff_moltype():
+    seq = RNA.make_seq("ACGG")
+    seq.add_feature(biotype="gene", name="test", spans=[(0, 2)])
+    got = DNA.make_seq(seq)
+    assert len(got.annotation_db) == 1
diff --git a/tests/test_core/test_profile.py b/tests/test_core/test_profile.py
index 54522f58a..d8b43ecb2 100644
--- a/tests/test_core/test_profile.py
+++ b/tests/test_core/test_profile.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Provides tests for classes and functions in profile.py
 """
+
 from collections import Counter
 from unittest import TestCase
 
@@ -538,7 +538,3 @@ def test_score_seq_obj(self):
         seq = DNA.make_seq("".join("ACTG"[i] for i in [3, 1, 2, 0, 2, 2, 3]))
         scores = pssm.score_seq(seq)
         assert_allclose(scores, [-4.481, -5.703, -2.966], atol=1e-3)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 0f1af2318..55d250a54 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -1,5 +1,6 @@
 """Unit tests for Sequence class and its subclasses.
 """
+
 import json
 import os
 import re
@@ -16,6 +17,7 @@
 import cogent3
 
 from cogent3._version import __version__
+from cogent3.core.alignment import Aligned
 from cogent3.core.moltype import (
     ASCII,
     BYTES,
@@ -39,6 +41,7 @@
     RnaSequence,
     Sequence,
     SeqView,
+    _coerce_to_seqview,
 )
 from cogent3.util.misc import get_object_provenance
 
@@ -1832,22 +1835,38 @@ def test_seqview_remove_gaps():
 
 
 def test_seqview_repr():
-    # case 1: Short sequence, defaults
+    # Short sequence, defaults
     seq = "ACGT"
     view = SeqView(seq)
-    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1)"
+    expected = (
+        "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0, seqid=None, seq_len=4)"
+    )
     assert repr(view) == expected
 
-    # case 2: Long sequence
+    # Long sequence
     seq = "ACGT" * 10
     view = SeqView(seq)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1)"
+    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=0, stop=40, step=1, offset=0, seqid=None, seq_len=40)"
     assert repr(view) == expected
 
-    # case 3: Non-zero start, stop, and step values
+    # Non-zero start, stop, and step values
     seq = "ACGT" * 10
     view = SeqView(seq, start=5, stop=35, step=2)
-    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=5, stop=35, step=2)"
+    expected = "SeqView(seq='ACGTACGTAC...TACGT', start=5, stop=35, step=2, offset=0, seqid=None, seq_len=40)"
+    assert repr(view) == expected
+
+    # offset
+    seq = "ACGT"
+    view = SeqView(seq, offset=5)
+    expected = (
+        "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=5, seqid=None, seq_len=4)"
+    )
+    assert repr(view) == expected
+
+    # seqid
+    seq = "ACGT"
+    view = SeqView(seq, seqid="seq1")
+    expected = "SeqView(seq='ACGT', start=0, stop=4, step=1, offset=0, seqid='seq1', seq_len=4)"
     assert repr(view) == expected
 
 
@@ -2167,6 +2186,24 @@ def test_relative_position_step_GT_one(integer_seq):
     assert got == 4
 
 
+@pytest.mark.parametrize("sliced", (False, True))
+@pytest.mark.parametrize("rev", (False, True))
+def test_seqview_copy(sliced, rev, integer_seq):
+    raw_data = integer_seq.seq
+    if rev:
+        integer_seq = integer_seq[::-1]
+        raw_data = raw_data[::-1]
+
+    slice_start = 2
+    slice_end = 4
+    sv = integer_seq[slice_start:slice_end]
+    copied = sv.copy(sliced=sliced)
+
+    assert copied.value == raw_data[slice_start:slice_end]
+    assert copied.reverse == integer_seq.reverse
+    assert sliced and copied.seq is not sv.seq or copied.seq is integer_seq.seq
+
+
 def test_relative_position_with_remainder(integer_seq):
     """tests relative_position when the index given is excluded from the view as it falls on
     a position that is 'stepped over'"""
@@ -2280,7 +2317,7 @@ def test_to_rich_dict(cls, with_offset):
     seq = "AAGGCC"
 
     if cls == Sequence:
-        seq = SeqView(seq).to_rich_dict()
+        seq = SeqView(seq, seqid="seq1").to_rich_dict()
 
     expect = {
         "name": "seq1",
@@ -2304,7 +2341,7 @@ def test_to_json(cls, with_offset):
 
     seq = "AAGGCC"
     if cls == Sequence:
-        seq = SeqView(seq).to_rich_dict()
+        seq = SeqView(seq, seqid="seq1").to_rich_dict()
 
     expect = {
         "name": "seq1",
@@ -2342,9 +2379,6 @@ def test_seqview_to_rich_dict():
     sv = SeqView(seq=parent)
     plus = sv.to_rich_dict()
     minus = sv[::-1].to_rich_dict()
-    for attr in ("type", "offset", "version"):
-        assert plus.pop(attr) == minus.pop(attr)
-
     plus = plus.pop("init_args")
     minus = minus.pop("init_args")
     assert plus.pop("seq") == minus.pop("seq")
@@ -2378,7 +2412,78 @@ def test_sliced_seqview_rich_dict(reverse):
         sv = sv[::-1]
     rd = sv.to_rich_dict()
     assert rd["init_args"]["seq"] == parent[sl]
-    assert rd["offset"] == 2
+    assert rd["init_args"]["offset"] == 2
+
+
+@pytest.mark.parametrize(
+    "sl",
+    (
+        slice(2, 5, 1),  # positive indices, positive step
+        slice(-8, -5, 1),  # negative indices, positive step
+        slice(4, 1, -1),  # positive indices, negative step
+        slice(-6, -9, -1),  # negative indices, negative step
+    ),
+)
+@pytest.mark.parametrize("offset", (4, 0))
+def test_parent_start_stop(sl, offset):
+    data = "0123456789"
+    # check our slice matches the expectation for rest of test
+    expect = "234" if sl.step > 0 else "432"
+    sv = SeqView(data)
+    sv.offset = offset
+    sv = sv[sl]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (2 + offset, 5 + offset)
+
+
+@pytest.mark.parametrize(
+    "sl",
+    (
+        slice(None, None, 1),  # slice whole sequence plus strand
+        slice(None, None, -1),  # slice whole sequence minus strand
+    ),
+)
+def test_parent_start_stop_limits(sl):
+    data = "0123456789"
+    # check our slice matches the expectation for rest of test
+    expect = data[sl]
+    sv = SeqView(data)
+    sv = sv[sl]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (0, 10)
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_parent_start_stop_empty(rev):
+    data = "0123456789"
+    # check our slice matches the expectation for rest of test
+    expect = ""
+    sv = SeqView(data)
+    sv = sv[0 : 0 : -1 if rev else 1]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (0, 0)
+
+
+@pytest.mark.parametrize("rev", (False, True))
+@pytest.mark.parametrize("index", range(9))
+def test_parent_start_stop_singletons(index, rev):
+    data = "0123456789"
+    start, stop = (-(10 - index), -(10 - index + 1)) if rev else (index, index + 1)
+    sl = slice(start, stop, -1 if rev else 1)
+    # check our slice matches the expectation for rest of test
+    expect = data[sl]
+    sv = SeqView(data)
+    sv = sv[sl]
+    assert sv.value == expect
+    # now check that start / stop are always the same
+    # irrespective of step sign
+    assert (sv.parent_start, sv.parent_stop) == (index, index + 1)
 
 
 def test_get_drawable(DATA_DIR):
@@ -2485,3 +2590,173 @@ def test_same_moltype(moltype):
     seq = moltype.make_seq("TCCAG")
     got = seq.to_moltype(moltype)
     assert got is seq
+
+
+def test_gapped_by_map_segment_iter():
+    moltype = get_moltype("dna")
+    m, seq = moltype.make_seq("-TCC--AG").parse_out_gaps()
+    g = list(seq.gapped_by_map_segment_iter(m, allow_gaps=True, recode_gaps=False))
+
+
+@pytest.mark.parametrize("rev", (False, True))
+@pytest.mark.parametrize("sliced", (False, True))
+@pytest.mark.parametrize("start_stop", ((None, None), (3, 7)))
+def test_copied_parent_coordinates(sliced, rev, start_stop):
+    orig_name = "orig"
+    seq = DNA.make_seq("ACGGTGGGAC", name=orig_name)
+    start, stop = start_stop
+    start = start or 0
+    stop = stop or len(seq)
+    sl = slice(start, stop)
+    seq = seq[sl]
+    sliced_name = "sliced"
+    seq.name = sliced_name
+    assert seq.name == sliced_name
+    if rev:
+        seq = seq.rc()
+    copied = seq.copy(sliced=sliced)
+    assert copied.name == sliced_name
+    # matches original
+    assert copied.parent_coordinates() == seq.parent_coordinates()
+    # and expected -- the coordinate name always reflects the underlying sequence
+    assert copied.parent_coordinates() == (orig_name, start, stop, -1 if rev else 1)
+
+
+@pytest.mark.parametrize("rev", (False, True))
+def test_parent_coordinates(rev):
+    seq = DNA.make_seq("ACGGTGGGAC")
+    seq = seq[1:1]
+    if rev:
+        seq = seq.rc()
+    seq.name = "sliced"  # this assignment does not affect the
+    # note that when a sequence has zero length, the parent seqid is None
+    assert seq.parent_coordinates() == (None, 0, 0, 1)
+
+
+def test_seqview_seqid():
+    sv = SeqView("ACGGTGGGAC")
+    assert sv.seqid is None
+
+    sv = SeqView("ACGGTGGGAC", seqid="seq1")
+    assert sv.seqid == "seq1"
+
+
+def test_seqview_rich_dict_round_trip_seqid():
+    sv = SeqView("ACGGTGGGAC", seqid="seq1")
+    rd = sv.to_rich_dict()
+    assert rd["init_args"]["seqid"] == "seq1"
+
+    got = SeqView.from_rich_dict(rd)
+    assert got.seqid == "seq1"
+
+    sv = SeqView("ACGGTGGGAC")
+    rd = sv.to_rich_dict()
+    assert rd["init_args"]["seqid"] == None
+
+    got = SeqView.from_rich_dict(rd)
+    assert got.seqid == None
+
+
+def test_seqview_slice_propagates_seqid():
+    sv = SeqView("ACGGTGGGAC", seqid="seq1")
+    sliced_sv = sv[1:8:2]
+    assert sliced_sv.seqid == "seq1"
+
+    copied_sv = sliced_sv.copy(sliced=False)
+    assert copied_sv.seqid == "seq1"
+
+    copied_sliced_sv = sliced_sv.copy(sliced=True)
+    assert copied_sliced_sv.seqid == "seq1"
+
+
+@pytest.mark.parametrize("cls", (Aligned, Sequence, SeqView, ArraySequence, str, bytes))
+def test_coerce_to_seqview(cls):
+    seq = "AC--GGTGGGAC"
+    seqid = "seq1"
+    if cls in (str, bytes):
+        got = _coerce_to_seqview(seq, seqid, preserve_case=True, checker=(lambda x: x))
+    elif cls is Aligned:
+        got = _coerce_to_seqview(
+            cls(*Sequence(seq).parse_out_gaps()),
+            seqid,
+            preserve_case=True,
+            checker=(lambda x: x),
+        )
+    else:
+        got = _coerce_to_seqview(
+            cls(seq), seqid, preserve_case=True, checker=(lambda x: x)
+        )
+    assert got.value == seq
+    assert isinstance(got, SeqView)
+
+
+def test_sequences_propogates_seqid():
+    # creating a name Sequence propagates the seqid to the SeqView.
+    seq = Sequence("ACGGTGGGAC", name="seq1")
+    assert seq._seq.seqid == "seq1"
+
+    # renaming the Sequence deosnt change the seqid of the SeqView.
+    seq.name = "seq2"
+    assert seq.name == "seq2"
+    assert seq._seq.seqid == "seq1"
+
+    # creating a name Aligned propagates the seqid to the SeqView.
+    seq = Aligned(*Sequence("ACG--G--GAC", name="seq1").parse_out_gaps())
+    assert seq.data._seq.seqid == "seq1"
+
+    # creating a Sequence with a seqview does not change the seqid of the SeqView.
+    seq = Sequence(SeqView("ACGGTGGGAC", seqid="parent_name"), name="seq_name")
+    assert seq.name == "seq_name"
+    assert seq._seq.seqid == "parent_name"
+
+    # creating a Sequence with an unnamed seqview does not name the SeqView.
+    seq = Sequence(SeqView("ACGGTGGGAC"), name="seq_name")
+    assert seq.name == "seq_name"
+    assert seq._seq.seqid == None
+
+
+def test_make_seq_assigns_to_seqview():
+    seq = cogent3.make_seq("ACGT", name="s1")
+    assert seq.name == seq._seq.seqid == "s1"
+
+
+def test_empty_seqview_translate_position():
+    sv = SeqView("")
+    assert sv.absolute_position(0) == 0
+    assert sv.relative_position(0) == 0
+
+
+@pytest.mark.parametrize("start", (None, 0, 1, 10, -1, -10))
+@pytest.mark.parametrize("stop", (None, 10, 8, 1, 0, -1, -11))
+@pytest.mark.parametrize("step", (None, 1, 2, -1, -2))
+@pytest.mark.parametrize("length", (1, 8, 999))
+def test_seqview_seq_len_init(start, stop, step, length):
+    # seq_len is length of seq when None
+    seq_data = "A" * length
+    sv = SeqView(seq_data, start=start, stop=stop, step=step)
+    expect = len(seq_data)
+    # Check property and slot
+    assert sv.seq_len == expect
+    assert sv._seq_len == expect
+
+
+@pytest.mark.parametrize("seq, seq_len", [("A", 0), ("", 1), ("A", 2)])
+def test_seqview_seq_len_mismatch(seq, seq_len):
+    # If provided, seq_len must match len(seq)
+    with pytest.raises(AssertionError):
+        SeqView(seq, seq_len=seq_len)
+
+
+def test_seqview_copy_propagates_seq_len():
+    seq = "ACGGTGGGAC"
+    sv = SeqView(seq)
+    copied = sv.copy()
+    assert copied.seq_len == len(seq)
+
+
+def test_seqview_seq_len_modified_seq():
+    seq = "ACGGTGGGAC"
+    sv = SeqView(seq)
+
+    sv.seq = "ATGC"  # this should not modify seq_len
+    assert sv.seq_len == len(seq)
diff --git a/tests/test_core/test_tree.py b/tests/test_core/test_tree.py
index 34b016dae..ef43a245a 100644
--- a/tests/test_core/test_tree.py
+++ b/tests/test_core/test_tree.py
@@ -1,5 +1,6 @@
 """Tests of classes for dealing with trees and phylogeny.
 """
+
 import json
 import os
 import pathlib
diff --git a/tests/test_draw/test_dendrogram.py b/tests/test_draw/test_dendrogram.py
index 35bc45757..bd497a66d 100644
--- a/tests/test_draw/test_dendrogram.py
+++ b/tests/test_draw/test_dendrogram.py
@@ -189,7 +189,3 @@ def test_scale_bar_place(self):
         # if we set scale_bar None, there should be shapes
         dnd.scale_bar = None
         self.assertIsNone(dnd.figure.layout.get("shapes"))
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_draw/test_dotplot.py b/tests/test_draw/test_dotplot.py
index 6b08408ba..a113c039b 100644
--- a/tests/test_draw/test_dotplot.py
+++ b/tests/test_draw/test_dotplot.py
@@ -1,27 +1,18 @@
 from unittest import TestCase
 
+import numpy
+
 from cogent3 import DNA, make_unaligned_seqs
 from cogent3.core.alignment import Aligned, ArrayAlignment
-from cogent3.draw.dotplot import (
-    Dotplot,
-    _convert_input,
-    get_align_coords,
-    len_seq,
-    not_gap,
-)
+from cogent3.core.location import IndelMap
+from cogent3.draw.dotplot import Dotplot, _convert_input, get_align_coords
 
 
 class TestUtilFunctions(TestCase):
     def test_len_seq(self):
         """returns length of sequence minus gaps"""
         m, seq = DNA.make_seq("ACGGT--A").parse_out_gaps()
-        self.assertEqual(len_seq(m), 6)
-
-    def test_not_gap(self):
-        """distinguishes Map instances that include gap or not"""
-        m, seq = DNA.make_seq("ACGGT--A").parse_out_gaps()
-        self.assertTrue(not_gap(m[0]))
-        self.assertFalse(not_gap(m[5]))
+        self.assertEqual(m.parent_length, 6)
 
     def test_convert_input(self):
         """converts data for dotplotting"""
@@ -57,7 +48,8 @@ def test_get_align_coords(self):
         m1, seq1 = DNA.make_seq("ACGGTTTA").parse_out_gaps()
         m2, seq2 = DNA.make_seq("GGGGTTTA").parse_out_gaps()
         paths = get_align_coords(m1, m2, aligned=True)
-        self.assertEqual(paths.get_coords(), ([0, len(seq1)], [0, len(seq1)]))
+        # display ranges are inclusive, thus length - 1
+        self.assertEqual(paths.get_coords(), ([0, len(seq1) - 1], [0, len(seq1) - 1]))
 
         # raises an exception if the Aligned seqs are different lengths
         m1, seq1 = DNA.make_seq("ACGGTTTA").parse_out_gaps()
@@ -67,6 +59,13 @@ def test_get_align_coords(self):
 
     def test_display2d(self):
         """correctly constructs a Display2d"""
+        #           111111
+        # 0123456789012345
+        #            11111
+        #  012345678901234
+        # -TGATGTAAGGTAGTT
+        # CTGG---AAG---GGT
+        # 0123   456   789
         # check alignment coords are correct
         dp = Dotplot("-TGATGTAAGGTAGTT", "CTGG---AAG---GGT", is_aligned=True, window=5)
         expect = [0, 2, None, 6, 8, None, 12, 14], [1, 3, None, 4, 6, None, 7, 9]
@@ -80,7 +79,7 @@ def test_display2d(self):
         self.assertEqual(traces[-1].name, "Alignment")
         self.assertEqual(traces[0].name, "+ strand")
         # check + strand has integers/float/None
-        expect = {int, float, type(None)}
+        expect = {int, float, type(None), numpy.int64, numpy.int32}
         for trace in traces:
             for axis in "xy":
                 got = {type(v) for v in trace[axis]}
@@ -149,5 +148,30 @@ def test_dotplot_title(self):
         self.assertEqual(dp.figure.layout.title, "")
 
 
-if __name__ == "__main__":
-    main()
+def test_aligned_path():
+    imap1 = IndelMap(
+        gap_pos=numpy.array([4, 5, 6, 8, 10]),
+        cum_gap_lengths=numpy.array([6, 10, 12, 14, 15]),
+        parent_length=10,
+    )
+    imap2 = IndelMap(
+        gap_pos=numpy.array([], dtype=int),
+        cum_gap_lengths=numpy.array([], dtype=int),
+        parent_length=25,
+    )
+
+    coords = get_align_coords(imap1, imap2)
+
+    assert coords.get_coords() == (
+        [0, 3, None, 4, 4, None, 5, 5, None, 6, 7, None, 8, 9],
+        [0, 3, None, 10, 10, None, 15, 15, None, 18, 19, None, 22, 23],
+    )
+
+
+def test_dotplot_unaligned():
+    seqs = make_unaligned_seqs(dict(a="ACGGT", b="CGTT"), moltype="dna")
+    dp = seqs.dotplot(window=3, k=2)
+    assert dp
+    # trigger building traces
+    _ = dp.figure
+    assert len(dp.traces[0].x)
diff --git a/tests/test_draw/test_logo.py b/tests/test_draw/test_logo.py
index 2b3cd7cd4..e3ec26041 100644
--- a/tests/test_draw/test_logo.py
+++ b/tests/test_draw/test_logo.py
@@ -83,7 +83,3 @@ def test_input_conversion(self):
         expect = [base, None]
         got = _char_hts_as_lists(data)
         self.assertEqual(got, expect)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_draw/test_shapes.py b/tests/test_draw/test_shapes.py
index f46e99a90..6349b607b 100644
--- a/tests/test_draw/test_shapes.py
+++ b/tests/test_draw/test_shapes.py
@@ -28,7 +28,3 @@ def test_height_property(self):
         rectangle.shift(5, 2)
 
         self.assertTrue(rectangle.height == 7, "Height is consistent with shift")
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_evolve/test_best_likelihood.py b/tests/test_evolve/test_best_likelihood.py
index 7466c0752..f1ed80513 100644
--- a/tests/test_evolve/test_best_likelihood.py
+++ b/tests/test_evolve/test_best_likelihood.py
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 import math
 
 from unittest import TestCase
@@ -134,7 +132,3 @@ def test_BestLogLikelihood(self):
         assert_allclose(obs, expect)
         lnL, l = BestLogLikelihood(self.aln, DNA.alphabet, return_length=True)
         self.assertEqual(l, len(self.aln))
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_evolve/test_coevolution.py b/tests/test_evolve/test_coevolution.py
index e05dc8fb9..54c6e1e67 100755
--- a/tests/test_evolve/test_coevolution.py
+++ b/tests/test_evolve/test_coevolution.py
@@ -339,25 +339,6 @@ def test_coevolve_position(self):
             sca_position(aln, position=0, cutoff=cutoff),
         )
 
-    def test_coevolve_alignment(self):
-        """coevolve_alignment: returns same as alignment methods"""
-        aln = ArrayAlignment(data={"1": "AC", "2": "AC"}, moltype=PROTEIN)
-        t = make_tree(treestring="(1:0.5,2:0.5);")
-        cutoff = 0.50
-        # mi_alignment == coevolve_alignment(mi_alignment,...)
-        assert_allclose(coevolve_alignment(mi_alignment, aln), mi_alignment(aln))
-        assert_allclose(coevolve_alignment(mip_alignment, aln), mip_alignment(aln))
-        assert_allclose(coevolve_alignment(mia_alignment, aln), mia_alignment(aln))
-        assert_allclose(coevolve_alignment(nmi_alignment, aln), nmi_alignment(aln))
-        assert_allclose(
-            coevolve_alignment(ancestral_state_alignment, aln, tree=t),
-            ancestral_state_alignment(aln, tree=t),
-        )
-        assert_allclose(
-            coevolve_alignment(sca_alignment, aln, cutoff=cutoff),
-            sca_alignment(aln, cutoff=cutoff),
-        )
-
     def test_coevolve_alignments_validation_idenifiers(self):
         """coevolve_alignments_validation: seq/tree validation functions"""
         method = sca_alignment
@@ -1852,7 +1833,6 @@ def test_get_subalignments(self):
             data={1: "AAAA", 2: "AAAC", 3: "AACG", 4: "ACCT", 5: "ACG-"},
             moltype=PROTEIN,
         )
-        sub_aln_0C = {}
         sub_aln_1A = ArrayAlignment(
             data={1: "AAAA", 2: "AAAC", 3: "AACG"}, moltype=PROTEIN
         )
@@ -1860,7 +1840,7 @@ def test_get_subalignments(self):
         sub_aln_2G = ArrayAlignment(data={5: "ACG-"}, moltype=PROTEIN)
 
         self.assertEqual(get_subalignments(aln, 0, ["A"]), [sub_aln_0A])
-        self.assertEqual(get_subalignments(aln, 0, ["C"]), [sub_aln_0C])
+        self.assertEqual(get_subalignments(aln, 0, ["C"]), [])
         self.assertEqual(get_subalignments(aln, 1, ["A"]), [sub_aln_1A])
         self.assertEqual(get_subalignments(aln, 1, ["C"]), [sub_aln_1C])
         self.assertEqual(
diff --git a/tests/test_evolve/test_distance.py b/tests/test_evolve/test_distance.py
index 8bc121d7b..367896538 100644
--- a/tests/test_evolve/test_distance.py
+++ b/tests/test_evolve/test_distance.py
@@ -922,10 +922,6 @@ def test_to_table(self):
         self.assertEqual(table["A", "A"], 0)
 
 
-if __name__ == "__main__":
-    main()
-
-
 @pytest.fixture
 def min_working_example_dmat():
     return DistanceMatrix(
diff --git a/tests/test_evolve/test_likelihood_function.py b/tests/test_evolve/test_likelihood_function.py
index 0a3d8acc3..5c32b8ea5 100644
--- a/tests/test_evolve/test_likelihood_function.py
+++ b/tests/test_evolve/test_likelihood_function.py
@@ -9,6 +9,7 @@
     
     checking that the object resets on tree change, model change, etc
 """
+
 import json
 import os
 import warnings
@@ -2261,8 +2262,8 @@ def test_simulate_alignment3():
     assert re.sub("[ATCG]", "x", simulated.to_dict()["a"]) == "x??xxxxxx?"
 
 
-def test_get_lengths_as_ens_matches_manual_calc():
-    """lengths as ENS for a non-reversible model matches a manual calculation"""
+@pytest.fixture(scope="session")
+def gn_mod():
     mprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
     # unequal lengths
     lf = GN().make_likelihood_function(make_tree("(a:0.1,b:0.1,c:0.1)"))
@@ -2272,10 +2273,15 @@ def test_get_lengths_as_ens_matches_manual_calc():
     for par_name in lf.model.get_param_list():
         lf.set_param_rule(par_name, init=init)
         init += 0.1
+    return lf
 
-    len_dict = lf.get_lengths_as_ens()
+
+def test_get_lengths_as_ens_matches_manual_calc(gn_mod):
+    """lengths as ENS for a non-reversible model matches a manual calculation"""
+    mprobs = numpy.array([0.1, 0.2, 0.3, 0.4])
+    len_dict = gn_mod.get_lengths_as_ens()
     expect = expected_number_subs(
-        mprobs, lf.get_rate_matrix_for_edge("a", calibrated=False), 1.0
+        mprobs, gn_mod.get_rate_matrix_for_edge("a", calibrated=False), 1.0
     )
     assert_allclose(len_dict["a"], expect)
 
@@ -2289,4 +2295,125 @@ def test_expm_zero_lengths(expm, DATA_DIR):
     sm = GTR()
     lf = sm.make_likelihood_function(tree=tree, expm=expm)
     lf.set_alignment(aln)
-    lf.optimise(max_evaluations=5, show_progress=False)
+    lf.optimise(max_evaluations=5, show_progress=False, limit_action="ignore")
+
+
+def test_lengths_as_ens_model_mix(DATA_DIR):
+    aln = load_aligned_seqs((DATA_DIR / "brca1_5.paml"), moltype="dna")
+    names = ["Mouse", "Human", "HowlerMon"]
+    aln = aln.take_seqs(names)
+    tree = make_tree((f"({names[0]}:0.0,{names[1]}:0.0,{names[2]}:0.0)"))
+    sm = GTR()
+    lf = sm.make_likelihood_function(tree=tree, discrete_edges=["Mouse"])
+    lf.set_alignment(aln)
+    lengths = lf.get_lengths_as_ens()
+    assert lengths["Mouse"] is None
+    assert lengths["Human"] == lengths["HowlerMon"] == 1
+
+
+@pytest.mark.parametrize("discrete_time", (False, True)[1:])
+def test_get_annotated_tree_model_mix(DATA_DIR, discrete_time):
+    aln = load_aligned_seqs((DATA_DIR / "brca1_5.paml"), moltype="dna")
+    names = ["Mouse", "Human", "HowlerMon"]
+    aln = aln.take_seqs(names)
+    tree = make_tree((f"({names[0]}:0.0,{names[1]}:0.0,{names[2]}:0.0)"))
+    sm = GTR()
+    lf = sm.make_likelihood_function(
+        tree=tree, discrete_edges=["Mouse"] if discrete_time else None
+    )
+    lf.set_alignment(aln)
+    lf.optimise(max_evaluations=5, show_progress=False, limit_action="ignore")
+    anno_tree = lf.get_annotated_tree(length_as="ENS")
+    assert isinstance(anno_tree, type(tree))
+
+
+@pytest.fixture(scope="session")
+def mixed_model(DATA_DIR):
+    aln = load_aligned_seqs((DATA_DIR / "brca1_5.paml"), moltype="dna")
+    names = ["Mouse", "Human", "HowlerMon"]
+    aln = aln.take_seqs(names)
+    tree = make_tree((f"({names[0]}:0.0,{names[1]}:0.0,{names[2]}:0.0)"))
+    sm = GTR()
+    lf = sm.make_likelihood_function(tree=tree, discrete_edges=["Mouse"])
+    lf.set_alignment(aln)
+    return lf
+
+
+@pytest.fixture(scope="session")
+def bh_model(DATA_DIR):
+    aln = load_aligned_seqs((DATA_DIR / "brca1_5.paml"), moltype="dna")
+    names = ["Mouse", "Human", "HowlerMon"]
+    aln = aln.take_seqs(names)
+    tree = make_tree((f"({names[0]}:0.0,{names[1]}:0.0,{names[2]}:0.0)"))
+    sm = get_model("BH")
+    lf = sm.make_likelihood_function(tree=tree)
+    lf.set_alignment(aln)
+    return lf
+
+
+def test_rate_matrix_for_edge_mixed_discrete(mixed_model):
+    from cogent3.recalculation.scope import InvalidScopeError
+    from cogent3.util.dict_array import DictArray
+
+    with pytest.raises(InvalidScopeError):
+        mixed_model.get_rate_matrix_for_edge("Mouse")
+
+    Q = mixed_model.get_rate_matrix_for_edge("Human")
+
+    assert isinstance(Q, DictArray)
+
+
+def test_get_paralinear_metric_mixed(mixed_model):
+    # should not fail with a mixed discrete, continuous-time model
+    dists = mixed_model.get_paralinear_metric()
+    # making sure have no None's
+    assert None not in set(dists.values())
+
+
+def test_get_lengths_as_ens_mixed(mixed_model):
+    # should not fail with a mixed discrete, continuous-time model
+    lengths = mixed_model.get_lengths_as_ens()
+    # and the length for discrete-time edge Mouse should be None
+    assert lengths["Mouse"] is None
+    # comparing against default value
+    assert lengths["Human"] == lengths["HowlerMon"] == 1
+
+
+def test_get_lengths_as_ens_discrete(bh_model):
+    # should not fail with a discrete-time model
+    lengths = bh_model.get_lengths_as_ens()
+    assert lengths["Human"] is lengths["HowlerMon"] is lengths["Mouse"] is None
+
+
+def test_get_annotated_tree_mixed(mixed_model):
+    # should not fail with a mixed discrete, continuous-time model
+    tree = mixed_model.get_annotated_tree(length_as="ENS")
+    # and the length for discrete-time edge Mouse should be None
+    mouse = tree.get_node_matching_name("Mouse")
+    assert mouse.length is None
+    assert mouse.params["ENS"] is None
+    assert mouse.params["paralinear"] is not None
+
+
+def test_get_ens_tree_mixed(mixed_model):
+    # should not fail with a mixed discrete, continuous-time model
+    tree = mixed_model.get_ens_tree()
+    # and the length for discrete-time edge Mouse should be None
+    mouse = tree.get_node_matching_name("Mouse")
+    assert mouse.length is None
+
+
+def test_get_ens_tree_gn(gn_mod):
+    # should not fail with a mixed discrete, continuous-time model
+    tree = gn_mod.get_ens_tree()
+    ens = gn_mod.get_lengths_as_ens()
+    tree_lengths = {
+        edge.name: edge.length for edge in tree.postorder() if edge.name != "root"
+    }
+    assert ens == tree_lengths
+
+
+def test_get_ens_tree_discrete(bh_model):
+    # should fail with a discrete-time model
+    with pytest.raises(TypeError):
+        bh_model.get_ens_tree()
diff --git a/tests/test_evolve/test_models.py b/tests/test_evolve/test_models.py
index 6dc29e4b1..8574bb3c2 100644
--- a/tests/test_evolve/test_models.py
+++ b/tests/test_evolve/test_models.py
@@ -128,7 +128,3 @@ def test_model_description(self):
             func = getattr(models_module, abbrev)
             doc = func.__doc__.split()
             self.assertEqual(desc.split(), doc)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_evolve/test_newq.py b/tests/test_evolve/test_newq.py
index 1df96fbc6..be1af009e 100644
--- a/tests/test_evolve/test_newq.py
+++ b/tests/test_evolve/test_newq.py
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 import warnings
 
 from unittest import TestCase
@@ -121,7 +119,7 @@ def test_lf_display(self):
         """str of likelihood functions should not fail"""
         for dummy, model in self.ordered_by_complexity:
             di = TimeReversibleNucleotide(motif_length=2, mprob_model=model)
-            di.adapt_motif_probs(self.cond_root_probs, auto=True)
+            di.adapt_motif_probs(self.cond_root_probs)
             lf = di.make_likelihood_function(self.tree)
             str(lf)
 
@@ -170,7 +168,7 @@ def test_reconstruct_ancestor(self):
         models"""
         for mprobs, model in self.ordered_by_complexity:
             di = TimeReversibleNucleotide(motif_length=2, mprob_model=model)
-            di.adapt_motif_probs(mprobs, auto=True)
+            di.adapt_motif_probs(mprobs)
             lf = di.make_likelihood_function(self.tree)
             lf.set_param_rule("length", is_independent=False, init=0.4)
             lf.set_alignment(self.aln)
@@ -388,7 +386,3 @@ def test_get_motif_probs_by_node_mg94(self):
         lf.set_alignment(aln)
         with self.assertRaises(NotImplementedError):
             _ = lf.get_motif_probs_by_node()
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_evolve/test_ns_substitution_model.py b/tests/test_evolve/test_ns_substitution_model.py
index d7dfb0fb0..652cf1c14 100644
--- a/tests/test_evolve/test_ns_substitution_model.py
+++ b/tests/test_evolve/test_ns_substitution_model.py
@@ -314,7 +314,3 @@ def test_nr_protein(self):
         sm = NonReversibleProtein(predicates=preds)
         got = sm.get_param_list()
         self.assertEqual(got, ["D>K", "R>V"])
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_evolve/test_parameter_controller.py b/tests/test_evolve/test_parameter_controller.py
index fcb284f12..c61b3d72b 100644
--- a/tests/test_evolve/test_parameter_controller.py
+++ b/tests/test_evolve/test_parameter_controller.py
@@ -214,13 +214,9 @@ def test_bounds(self):
 
         # Out of bounds value should warn and keep bounded
         with warnings.catch_warnings(record=True) as w:
-            lf.set_param_rule("length", lower=0, upper=2)
+            lf.set_param_rule("length", lower=0, upper=2, warn=True)
             self.assertTrue(len(w), "No warning issued")
         self.assertEqual(lf.get_param_value("length", edge="a"), 2)
 
         # upper < lower bounds should fail
         self.assertRaises(ValueError, lf.set_param_rule, "length", lower=2, upper=0)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_evolve/test_substitution_model.py b/tests/test_evolve/test_substitution_model.py
index 5fc9a98df..82c4c9617 100644
--- a/tests/test_evolve/test_substitution_model.py
+++ b/tests/test_evolve/test_substitution_model.py
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 import os
 
 from unittest import TestCase
@@ -402,7 +400,3 @@ def test_get_param_list(self):
 
     # need to ensure entering motif probs that sum to 1, that motif sets are
     # the same
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_format/test_bedgraph.py b/tests/test_format/test_bedgraph.py
index 8c3d8100a..38e197278 100644
--- a/tests/test_format/test_bedgraph.py
+++ b/tests/test_format/test_bedgraph.py
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 from unittest import TestCase
 
 from cogent3.util.table import Table
@@ -188,7 +186,3 @@ def test_raises_on_incorrect_format_val(self):
             color=(255, 0, 0),
             windowingFunction="sqrt",
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_format/test_clustal.py b/tests/test_format/test_clustal.py
index e65649a0a..28fcbad39 100644
--- a/tests/test_format/test_clustal.py
+++ b/tests/test_format/test_clustal.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Tests for Clustal sequence format writer.
 """
+
 from unittest import TestCase
 
 from cogent3.core.alignment import Alignment
@@ -95,7 +95,3 @@ def test_clustal_from_alignment_reordered(self):
             clustal_from_alignment(self.alignment_object, wrap=2),
             self.clustal_with_label_lw2_reordered,
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_format/test_fasta.py b/tests/test_format/test_fasta.py
index 04e611dc8..d3b83dec1 100644
--- a/tests/test_format/test_fasta.py
+++ b/tests/test_format/test_fasta.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Tests for FASTA sequence format writer.
 """
+
 from unittest import TestCase
 
 from cogent3.core.alignment import Alignment
@@ -51,7 +51,3 @@ def test_alignment_to_fasta(self):
             alignment_to_fasta(self.alignment_dict, block_size=2),
             self.fasta_with_label_lw2,
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_distance_transform.py b/tests/test_maths/test_distance_transform.py
index 40423af9f..99ad0b4da 100644
--- a/tests/test_maths/test_distance_transform.py
+++ b/tests/test_maths/test_distance_transform.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 """Unit tests for distance_transform.py functions.
 """
 
@@ -555,7 +554,3 @@ def test_dist_bray_curtis_magurran2(self):
                 ]
             ),
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_geometry.py b/tests/test_maths/test_geometry.py
index c5d2697f5..c3a8ce55f 100644
--- a/tests/test_maths/test_geometry.py
+++ b/tests/test_maths/test_geometry.py
@@ -1,5 +1,5 @@
-#!/usr/bin/env python
 """Tests of the geometry package."""
+
 from math import sqrt
 from unittest import TestCase
 
@@ -151,7 +151,3 @@ def test_multiplicative_replacement(self):
         assert isclose(
             sum(u), 1
         ), "Multiplicative replacement does not yield a composition."
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_matrix_exponential_integration.py b/tests/test_maths/test_matrix_exponential_integration.py
index a50ebc5b7..b99043379 100644
--- a/tests/test_maths/test_matrix_exponential_integration.py
+++ b/tests/test_maths/test_matrix_exponential_integration.py
@@ -129,7 +129,3 @@ def get_calibrated_Q(R):
         length = 0.2
         got = expm.expected_number_subs(moprobs, Q, length)
         self.assertNotAlmostEqual(got, length)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_matrix_logarithm.py b/tests/test_maths/test_matrix_logarithm.py
index 93ff43526..a25fcecd5 100644
--- a/tests/test_maths/test_matrix_logarithm.py
+++ b/tests/test_maths/test_matrix_logarithm.py
@@ -1,5 +1,5 @@
-#!/usr/bin/env python
 """Unit tests for matrix logarithm."""
+
 from unittest import TestCase
 
 from numpy import array
@@ -104,7 +104,3 @@ def test_is_generator_unique(self):
             ]
         )
         self.assertRaises(NotImplementedError, is_generator_unique, q_raise)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_measure.py b/tests/test_maths/test_measure.py
index 943f2a43d..af664a6fd 100644
--- a/tests/test_maths/test_measure.py
+++ b/tests/test_maths/test_measure.py
@@ -1,6 +1,6 @@
 from unittest import TestCase
 
-from numpy import arange, array, diag_indices, dot, finfo, float64
+from numpy import diag_indices, dot, finfo, float64
 from numpy.random import random
 from numpy.testing import assert_allclose
 
@@ -284,7 +284,3 @@ def test_jsm(self):
             err_msg="Testing case4 for jsm failed",
             atol=self.atol,
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_period.py b/tests/test_maths/test_period.py
index bcf8dff5b..662eab8e0 100644
--- a/tests/test_maths/test_period.py
+++ b/tests/test_maths/test_period.py
@@ -347,7 +347,3 @@ def test_hybrid_returns_all(self):
         self.assertEqual(hybrid_ipdft_autocorr_stats[0], hybrid_x)
         self.assertEqual(hybrid_ipdft_autocorr_stats[1], ipdft_pwr)
         self.assertEqual(hybrid_ipdft_autocorr_stats[2], auto_x)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_contingency.py b/tests/test_maths/test_stats/test_contingency.py
index 78188f135..0ffec9639 100644
--- a/tests/test_maths/test_stats/test_contingency.py
+++ b/tests/test_maths/test_stats/test_contingency.py
@@ -238,7 +238,3 @@ def test_category_counts_from_non_int_arrays(self):
             a = numpy.array([[31, -36], [58, 138]], dtype=int)
             darr = DictArrayTemplate(["syn", "nsyn"], ["Ts", "Tv"]).wrap(a)
             _ = CategoryCounts(darr)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_distribution.py b/tests/test_maths/test_stats/test_distribution.py
index 9f39ac71e..3dd7c6d1a 100644
--- a/tests/test_maths/test_stats/test_distribution.py
+++ b/tests/test_maths/test_stats/test_distribution.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 """Tests of statistical probability distribution integrals.
 
 Currently using tests against calculations in R, spreadsheets being unreliable.
@@ -21,7 +20,6 @@
     pdtr,
     pdtrc,
     pdtri,
-    poisson_exact,
     poisson_high,
     poisson_low,
     probability_points,
@@ -127,70 +125,6 @@ def test_tprob(self):
             for x, p in zip(self.values, probs[df]):
                 assert_almost_equal(tprob(x, df), p, decimal=4)
 
-    def test_poisson_low(self):
-        """Lower tail of poisson should match R for integer successes"""
-        # WARNING: Results only guaranteed for integer successes: floating
-        # point _should_ yield reasonable values, but R rounds to int.
-        expected = {
-            (0, 0): 1,
-            (0, 0.75): 0.4723666,
-            (0, 1): 0.3678794,
-            (0, 5): 0.006737947,
-            (0, 113.7): 4.175586e-50,
-            (2, 0): 1,
-            (2, 3): 0.4231901,
-            (2, 17.8): 3.296636e-06,
-            (17, 29.6): 0.008753318,
-            (180, 0): 1,
-            (180, 137.4): 0.999784,
-            (180, 318): 2.436995e-17,
-            (180, 1024): 8.266457e-233,
-        }
-        for key, value in list(expected.items()):
-            assert_allclose(poisson_low(*key), value, rtol=1e-6)
-
-    def test_poisson_high(self):
-        """Upper tail of poisson should match R for integer successes"""
-        # WARNING: Results only guaranteed for integer successes: floating
-        # point _should_ yield reasonable values, but R rounds to int.
-        expected = {
-            (0, 0): 0,
-            (0, 0.75): 0.5276334,
-            (0, 1): 0.6321206,
-            (0, 5): 0.993262,
-            (0, 113.7): 1,
-            (2, 0): 0,
-            (2, 3): 0.5768099,
-            (2, 17.8): 0.9999967,
-            (17, 29.6): 0.9912467,
-            (180, 0): 0,
-            (180, 137.4): 0.0002159856,
-            (180, 318): 1,
-            (180, 1024): 1,
-        }
-        for key, value in list(expected.items()):
-            assert_allclose(poisson_high(*key), value)
-
-    def test_poisson_exact(self):
-        """Poisson exact should match expected values from R"""
-        expected = {
-            (0, 0): 1,
-            (0, 0.75): 0.4723666,
-            (0, 1): 0.3678794,
-            (0, 5): 0.006737947,
-            (0, 113.7): 4.175586e-50,
-            (2, 0): 0,
-            (2, 3): 0.2240418,
-            (2, 17.8): 2.946919e-06,
-            (17, 29.6): 0.004034353,
-            (180, 0): 0,
-            (180, 137.4): 7.287501e-05,
-            (180, 318): 1.067247e-17,
-            (180, 1024): 6.815085e-233,
-        }
-        for key, value in list(expected.items()):
-            assert_allclose(poisson_exact(*key), value, rtol=1e-6)
-
     def test_binomial_series(self):
         """binomial_exact should match values from R on a whole series"""
         expected = list(
@@ -1262,7 +1196,3 @@ def test_theoretical_quantiles(self):
         )
         got = theoretical_quantiles(4, "t", 4)
         assert_almost_equal(got, expect)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_information_criteria.py b/tests/test_maths/test_stats/test_information_criteria.py
index e4edf271e..c9f676e91 100644
--- a/tests/test_maths/test_stats/test_information_criteria.py
+++ b/tests/test_maths/test_stats/test_information_criteria.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 from unittest import TestCase
 
 from numpy.testing import assert_allclose
@@ -22,7 +21,3 @@ def test_bic(self):
         """correctly compute BIC"""
         # against hand calculated
         assert_allclose(bic(-9.7039, 4, 13), 29.6675974298)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_jackknife.py b/tests/test_maths/test_stats/test_jackknife.py
index 64e152040..ed601be05 100644
--- a/tests/test_maths/test_stats/test_jackknife.py
+++ b/tests/test_maths/test_stats/test_jackknife.py
@@ -194,7 +194,3 @@ def test_tabular_properties(self):
         self.assertEqual(pvs.shape, (12, 2))
         ss = test_knife.summary_stats
         self.assertEqual(ss.shape, (1, 3))
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_ks.py b/tests/test_maths/test_stats/test_ks.py
index c0be4559a..41818de17 100644
--- a/tests/test_maths/test_stats/test_ks.py
+++ b/tests/test_maths/test_stats/test_ks.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 from unittest import TestCase
 
 from numpy.testing import assert_allclose
@@ -171,7 +170,3 @@ def test_ks2x(self):
     def test_ks_boot(self):
         """excercising the bootstrapped version of KS"""
         D, Pval = ks_boot(self.x1[:10], self.x2[:10], num_reps=10)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_number.py b/tests/test_maths/test_stats/test_number.py
index 265b534ec..e10227068 100644
--- a/tests/test_maths/test_stats/test_number.py
+++ b/tests/test_maths/test_stats/test_number.py
@@ -372,7 +372,3 @@ def test_to_categorical(self):
         with self.assertRaises(NotImplementedError):
             cats = number.CategoryCounter(d3)
             cats.to_categorical()
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_period.py b/tests/test_maths/test_stats/test_period.py
index 66dfa21a9..2e1c03e1a 100644
--- a/tests/test_maths/test_stats/test_period.py
+++ b/tests/test_maths/test_stats/test_period.py
@@ -270,7 +270,3 @@ def test_permutation_skips(self):
         )
         self.assertEqual(stat, 0.0)
         self.assertEqual(p, 1.0)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_maths/test_stats/test_special.py b/tests/test_maths/test_stats/test_special.py
index 5056c37fd..40109f97b 100644
--- a/tests/test_maths/test_stats/test_special.py
+++ b/tests/test_maths/test_stats/test_special.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Unit tests for special functions used in statistics.
 """
+
 import math
 
 from unittest import TestCase
@@ -8,159 +8,19 @@
 from numpy.testing import assert_allclose
 
 from cogent3.maths.stats.special import (
-    combinations,
-    combinations_exact,
     igami,
     incbi,
     ln_binomial,
-    ln_combinations,
-    ln_permutations,
     log1p,
     log_one_minus,
     ndtri,
     one_minus_exp,
-    permutations,
-    permutations_exact,
 )
 
 
 class SpecialTests(TestCase):
     """Tests miscellaneous functions."""
 
-    def test_permutations(self):
-        """permutations should return expected results"""
-        self.assertEqual(permutations(1, 1), 1)
-        self.assertEqual(permutations(2, 1), 2)
-        self.assertEqual(permutations(3, 1), 3)
-        self.assertEqual(permutations(4, 1), 4)
-        self.assertEqual(permutations(4, 2), 12)
-        self.assertEqual(permutations(4, 3), 24)
-        self.assertEqual(permutations(4, 4), 24)
-        assert_allclose(permutations(300, 100), 3.8807387193009318e239)
-
-    def test_permutations_errors(self):
-        """permutations should raise errors on invalid input"""
-        self.assertRaises(IndexError, permutations, 10, 50)
-        self.assertRaises(IndexError, permutations, -1, 50)
-        self.assertRaises(IndexError, permutations, 10, -5)
-
-    def test_permutations_float(self):
-        """permutations should use gamma function when floats as input"""
-        assert_allclose(permutations(1.0, 1), 1)
-        assert_allclose(permutations(2, 1.0), 2)
-        assert_allclose(permutations(3.0, 1.0), 3)
-        assert_allclose(permutations(4.0, 1), 4)
-        assert_allclose(permutations(4.0, 2.0), 12)
-        assert_allclose(permutations(4.0, 3.0), 24)
-        assert_allclose(permutations(4, 4.0), 24)
-        assert_allclose(permutations(300, 100), 3.8807387193009318e239)
-
-    def test_permutations_range(self):
-        """permutations should increase gradually with increasing k"""
-        start = 5  # permuations(10,5) = 30240
-        end = 6  # permutations(10,6) = 151200
-        step = 0.1
-        lower_lim = 30240
-        upper_lim = 151200
-        previous_value = 30239.9999
-        while start <= end:
-            obs = permutations(10, start)
-            assert lower_lim <= obs <= upper_lim
-            assert obs > previous_value
-            previous_value = obs
-            start += step
-
-    def test_permutations_exact(self):
-        """permutations_exact should return expected results"""
-        assert_allclose(permutations_exact(1, 1), 1)
-        assert_allclose(permutations_exact(2, 1), 2)
-        assert_allclose(permutations_exact(3, 1), 3)
-        assert_allclose(permutations_exact(4, 1), 4)
-        assert_allclose(permutations_exact(4, 2), 12)
-        assert_allclose(permutations_exact(4, 3), 24)
-        assert_allclose(permutations_exact(4, 4), 24)
-        assert_allclose(permutations_exact(300, 100) / 3.8807387193009318e239, 1.0)
-
-    def test_ln_permutations(self):
-        """ln_permutations should return expected results"""
-        assert_allclose(ln_permutations(1, 1), math.log(1))
-        assert_allclose(ln_permutations(2, 1), math.log(2))
-        assert_allclose(ln_permutations(3, 1.0), math.log(3))
-        assert_allclose(ln_permutations(4, 1), math.log(4))
-        assert_allclose(ln_permutations(4.0, 2), math.log(12))
-        assert_allclose(ln_permutations(4, 3.0), math.log(24))
-        assert_allclose(ln_permutations(4, 4), math.log(24))
-        assert_allclose(ln_permutations(300.0, 100), math.log(3.8807387193009318e239))
-
-    def test_combinations(self):
-        """combinations should return expected results when int as input"""
-        self.assertEqual(combinations(1, 1), 1)
-        self.assertEqual(combinations(2, 1), 2)
-        self.assertEqual(combinations(3, 1), 3)
-        self.assertEqual(combinations(4, 1), 4)
-        self.assertEqual(combinations(4, 2), 6)
-        self.assertEqual(combinations(4, 3), 4)
-        self.assertEqual(combinations(4, 4), 1)
-        self.assertEqual(combinations(20, 4), 19 * 17 * 15)
-        assert_allclose(combinations(300, 100), 4.1582514632578812e81)
-
-    def test_combinations_errors(self):
-        """combinations should raise errors on invalid input"""
-        self.assertRaises(IndexError, combinations, 10, 50)
-        self.assertRaises(IndexError, combinations, -1, 50)
-        self.assertRaises(IndexError, combinations, 10, -5)
-
-    def test_combinations_float(self):
-        """combinations should use gamma function when floats as input"""
-        assert_allclose(combinations(1.0, 1.0), 1)
-        assert_allclose(combinations(2.0, 1.0), 2)
-        assert_allclose(combinations(3.0, 1.0), 3)
-        assert_allclose(combinations(4.0, 1.0), 4)
-        assert_allclose(combinations(4.0, 2), 6)
-        assert_allclose(combinations(4, 3.0), 4)
-        assert_allclose(combinations(4.0, 4.0), 1)
-        assert_allclose(combinations(20.0, 4.0), 19 * 17 * 15)
-        assert_allclose(combinations(300, 100.0), 4.1582514632578812e81)
-
-    def test_combinations_range(self):
-        """combinations should decrease gradually with increasing k"""
-        start = 5  # combinations(10,5) = 252
-        end = 6  # combinations(10,6) = 210
-        step = 0.1
-        lower_lim = 210
-        upper_lim = 252
-        previous_value = 252.00001
-        while start <= end:
-            obs = combinations(10, start)
-            assert lower_lim <= obs <= upper_lim
-            assert obs < previous_value
-            previous_value = obs
-            start += step
-
-    def test_combinations_exact(self):
-        """combinations_exact should return expected results"""
-        self.assertEqual(combinations_exact(1, 1), 1)
-        self.assertEqual(combinations_exact(2, 1), 2)
-        self.assertEqual(combinations_exact(3, 1), 3)
-        self.assertEqual(combinations_exact(4, 1), 4)
-        self.assertEqual(combinations_exact(4, 2), 6)
-        self.assertEqual(combinations_exact(4, 3), 4)
-        self.assertEqual(combinations_exact(4, 4), 1)
-        self.assertEqual(combinations_exact(20, 4), 19 * 17 * 15)
-        assert_allclose(combinations_exact(300, 100), 4.1582514632578812e81)
-
-    def test_ln_combinations(self):
-        """ln_combinations should return expected results"""
-        assert_allclose(ln_combinations(1, 1), math.log(1))
-        assert_allclose(ln_combinations(2, 1), math.log(2))
-        assert_allclose(ln_combinations(3, 1), math.log(3))
-        assert_allclose(ln_combinations(4.0, 1), math.log(4))
-        assert_allclose(ln_combinations(4, 2.0), math.log(6))
-        assert_allclose(ln_combinations(4, 3), math.log(4))
-        assert_allclose(ln_combinations(4, 4.0), math.log(1))
-        assert_allclose(ln_combinations(20, 4), math.log(19 * 17 * 15))
-        assert_allclose(ln_combinations(300, 100), math.log(4.1582514632578812e81))
-
     def test_ln_binomial_integer(self):
         """ln_binomial should match R results for integer values"""
         expected = {
diff --git a/tests/test_maths/test_util.py b/tests/test_maths/test_util.py
index 68f0d06ac..f94959cda 100644
--- a/tests/test_maths/test_util.py
+++ b/tests/test_maths/test_util.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Provides tests for array.py
 """
+
 # SUPPORT2425
 # from __future__ import with_statement
 
@@ -167,7 +167,3 @@ def test_proportions_and_ratios(self):
 
         with self.assertRaises(AssertionError):
             ratios_to_proportions(1.0, [2.3, 1.1, -0.3])
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_blast.py b/tests/test_parse/test_blast.py
index 0e56d85a5..6513a3bdf 100644
--- a/tests/test_parse/test_blast.py
+++ b/tests/test_parse/test_blast.py
@@ -349,7 +349,3 @@ def test_FastaCmdTaxonomyParser(self):
         self.assertEqual(r0["scientific_name"], "Pinus sylvestris")
         self.assertEqual(r0["seq_id"], "gi|3021565|emb|AJ223314.1|PSAJ3314")
         self.assertEqual(r1["tax_id"], "228610")
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_blast_xml.py b/tests/test_parse/test_blast_xml.py
index 77ce9adf0..76a4f24b2 100644
--- a/tests/test_parse/test_blast_xml.py
+++ b/tests/test_parse/test_blast_xml.py
@@ -1,9 +1,3 @@
-#! /usr/bin/env python
-#
-# test_blast_xml.py
-#
-
-
 import xml.dom.minidom
 
 from unittest import TestCase
@@ -333,7 +327,3 @@ def test_best_hits_unique(self):
 """
     + HEADER_COMPLETE
 )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_cigar.py b/tests/test_parse/test_cigar.py
index 5f09808ff..e8547afb4 100644
--- a/tests/test_parse/test_cigar.py
+++ b/tests/test_parse/test_cigar.py
@@ -49,7 +49,7 @@ def test_slice_cigar(self):
                 slicealn1 = self.seq[loc1[0] : loc1[1]].gapped_by_map(map1)
                 assert ori1 == slicealn1
             else:
-                assert map1.length == len(ori1)
+                assert len(map1) == len(ori1)
 
             # test by_align = False
             map2, loc2 = slice_cigar(self.cigar_text, start, end, by_align=False)
@@ -84,7 +84,7 @@ def test_CigarParser(self):
                 start=start,
                 end=end,
             )
-            assert cmp_aln.to_dict() == slice_aln.to_dict()
+            assert cmp_aln.to_dict() == slice_aln.to_dict(), (start, end)
 
 
 if __name__ == "__main__":
diff --git a/tests/test_parse/test_clustal.py b/tests/test_parse/test_clustal.py
index af6ed2932..a1be993a6 100644
--- a/tests/test_parse/test_clustal.py
+++ b/tests/test_parse/test_clustal.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Unit tests for the clustal parsers.
 """
+
 from unittest import TestCase
 
 from cogent3.parse.clustal import (
@@ -133,7 +133,3 @@ def test_space_labels(self):
         self.assertEqual(
             result, ({"abc": ["uca"], "def ggg": ["ccc"]}, ["abc", "def ggg"])
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_fasta.py b/tests/test_parse/test_fasta.py
index a83af8e70..edbe40967 100644
--- a/tests/test_parse/test_fasta.py
+++ b/tests/test_parse/test_fasta.py
@@ -1,5 +1,6 @@
 """Unit tests for FASTA and related parsers.
 """
+
 import os
 
 from unittest import TestCase
diff --git a/tests/test_parse/test_genbank.py b/tests/test_parse/test_genbank.py
index c6007c775..0a93c67c7 100644
--- a/tests/test_parse/test_genbank.py
+++ b/tests/test_parse/test_genbank.py
@@ -1,5 +1,6 @@
 """Unit tests for the GenBank database parsers.
 """
+
 from unittest import TestCase
 
 import pytest
diff --git a/tests/test_parse/test_gff.py b/tests/test_parse/test_gff.py
index 68940f9c3..6f52734f3 100644
--- a/tests/test_parse/test_gff.py
+++ b/tests/test_parse/test_gff.py
@@ -1,5 +1,6 @@
 """Unit tests for GFF and related parsers.
 """
+
 import os
 import re
 
diff --git a/tests/test_parse/test_greengenes.py b/tests/test_parse/test_greengenes.py
index 4a649a7e0..53f3f7723 100644
--- a/tests/test_parse/test_greengenes.py
+++ b/tests/test_parse/test_greengenes.py
@@ -1,5 +1,3 @@
-#!/usr/bin/env python
-
 from unittest import TestCase
 
 from cogent3.parse.greengenes import (
@@ -243,6 +241,3 @@ def test_make_ignore_f(self):
 aligned_seq=unaligned
 END
 """
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_locuslink.py b/tests/test_parse/test_locuslink.py
index d5b96f439..2f5485103 100644
--- a/tests/test_parse/test_locuslink.py
+++ b/tests/test_parse/test_locuslink.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
 """Unit tests for locuslink-specific classes
 """
+
 from unittest import TestCase
 
 from cogent3.parse.locuslink import (
@@ -507,7 +507,3 @@ def test_LinesToLocusLink(self):
                 },
             ],
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_ncbi_taxonomy.py b/tests/test_parse/test_ncbi_taxonomy.py
index 36a18c92a..ccc62e8d5 100644
--- a/tests/test_parse/test_ncbi_taxonomy.py
+++ b/tests/test_parse/test_ncbi_taxonomy.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 """Tests of parsers for dealing with NCBI Taxonomy files.
 """
 
@@ -352,7 +351,3 @@ def test_getRankedDescendants(self):
         gr = tx[3].getRankedDescendants("group")
         self.assertEqual(gr, [tx[123]])
         assert tx[3] is tx["a"]
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_nexus.py b/tests/test_parse/test_nexus.py
index a3c766630..5211fc978 100644
--- a/tests/test_parse/test_nexus.py
+++ b/tests/test_parse/test_nexus.py
@@ -418,7 +418,3 @@ def test_load_seqs_interface(self):
         aln = load_aligned_seqs("data/nexus_aa.nxs")
         self.assertEqual(aln.num_seqs, 10)
         self.assertEqual(len(aln), 234)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_pamlmatrix.py b/tests/test_parse/test_pamlmatrix.py
index 42d594cef..b4c70837f 100644
--- a/tests/test_parse/test_pamlmatrix.py
+++ b/tests/test_parse/test_pamlmatrix.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 from io import StringIO
 from unittest import TestCase
 
@@ -52,7 +51,3 @@ def test_parse(self):
         matrix, freqs = PamlMatrixParser(StringIO(data))
         assert_equal(DSO78_matrix, matrix)
         assert_equal(DSO78_freqs, freqs)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_phylip.py b/tests/test_parse/test_phylip.py
index 2be410378..4ca394184 100644
--- a/tests/test_parse/test_phylip.py
+++ b/tests/test_parse/test_phylip.py
@@ -1,7 +1,6 @@
-#!/bin/env python
-# file cogent/parse/test_phylip.py
 """Unit tests for the phylip parser
 """
+
 from io import StringIO
 from unittest import TestCase
 
@@ -316,7 +315,3 @@ def test_get_align(self):
 GGCAGCCAATCACGGCAGCC
 """,
         )
-
-
-if __name__ == "__main__":
-    main()
diff --git a/tests/test_parse/test_tree.py b/tests/test_parse/test_tree.py
index 3cf1314d2..56b328096 100644
--- a/tests/test_parse/test_tree.py
+++ b/tests/test_parse/test_tree.py
@@ -1,5 +1,6 @@
 """Unit tests for tree parsers.
 """
+
 from unittest import TestCase
 
 from cogent3.core.tree import PhyloNode
diff --git a/tests/test_util/test_io.py b/tests/test_util/test_io.py
index a3cc8fd44..8cbc1e664 100644
--- a/tests/test_util/test_io.py
+++ b/tests/test_util/test_io.py
@@ -309,6 +309,7 @@ def test_open_zip_multi(tmp_dir):
     "mode",
     ("r", "rb", "rt", None),
 )
+@pytest.mark.internet
 def test_open_url(DATA_DIR, mode):
     """different open mode's all work"""
     # None value for open_url mode defaults to "r"
@@ -345,6 +346,7 @@ def test_open_url_local(DATA_DIR):
     assert remote_data.splitlines() == local_data.splitlines()
 
 
+@pytest.mark.internet
 def test_open_url_compressed(DATA_DIR):
     """comparing compressed file handling"""
     file_name = "formattest.fasta.gz"
diff --git a/tests/test_util/test_misc.py b/tests/test_util/test_misc.py
index bf51ada9f..1e35f6eec 100644
--- a/tests/test_util/test_misc.py
+++ b/tests/test_util/test_misc.py
@@ -1233,8 +1233,7 @@ def foo3():
     """
 
 
-def foo4():
-    ...
+def foo4(): ...
 
 
 _sum_expect = "some text"
@@ -1270,7 +1269,7 @@ def test_get_true_spans_absolute():
     got = get_true_spans(
         # abs  0  1  2  3  4  5  6  7  8  9
         array([1, 0, 0, 1, 1, 1, 1, 0, 1, 1]),
-        absolute_pos=False
+        absolute_pos=False,
         # u       0  1              2
     )
     assert_allclose(got, array([[0, 1], [2, 4], [3, 2]]))
diff --git a/tests/test_util/test_table.py b/tests/test_util/test_table.py
index d2e25c936..2e5067b2a 100644
--- a/tests/test_util/test_table.py
+++ b/tests/test_util/test_table.py
@@ -1,5 +1,6 @@
 """Unit tests for table.
 """
+
 import json
 import os
 import pathlib
diff --git a/tests/test_util/test_warning.py b/tests/test_util/test_warning.py
index e4dbe916d..08cfb6af1 100644
--- a/tests/test_util/test_warning.py
+++ b/tests/test_util/test_warning.py
@@ -227,3 +227,13 @@ def changed(a: int, b: int) -> int:
     assert any("argument x which will be removed" in warning for warning in warnings)
     assert any("argument b is discontinued" in warning for warning in warnings)
     assert any("function changed is discontinued" in warning for warning in warnings)
+
+
+def test_class_deprecated(recwarn):
+    class fooclass:
+        @deprecated_callable("2023.6", "Improved change function", is_discontinued=True)
+        def __init__(self): ...
+
+    fooclass()
+    warnings = [warning.message.args[0] for warning in recwarn.list]
+    assert any("fooclass is discontinued" in warning for warning in warnings)