Repository: goodmami/wn (language: python)

Issue:
Remove some references to Princeton WordNet
In various places in the documentation, it is claimed that OMW's English Wordnet derived from the Princeton WordNet's WNDB data *is* the Princeton data, but that is not allowed (see bond-lab/omw-data#5). It needs to be more clear that it is a derivative work that is (mostly) faithful to the original data, and any bugs in the repackaging are the responsibility of OMW and not Princeton University.

For example, on the README there is this:

![not-princeton-wordnet](https://user-images.githubusercontent.com/1428419/134567422-05ce6049-a55d-425d-bd98-247cbe1175e7.png)

In addition, the identifier `pwn` may need to be retired. In this case, it would be convenient if the `index.toml` file allowed for a way to indicate deprecated identifiers and what they should resolve to.
add a check function
I would like to add a module (check.py) that checks for various issues with a wordnet.  The goal is to help developers avoid producing wordnets with structural problems.  It would duplicate some of the functionality of the `validate.py` in the 
https://github.com/globalwordnet/english-wordnet/ and the checks of the https://github.com/globalwordnet/OMW but make them more widely available.   



Lemmatization returning too many results
The results when lemmatization is used are sometimes non-unique:

```python
>>> import wn
>>> from wn.morphy import Morphy
>>> en = wn.Wordnet('oewn:2021')  # without lemmatization; rely on alternative forms
>>> en.words('leaves')
[Word('oewn-leaf-n'), Word('oewn-leave-n')]
>>> en = wn.Wordnet('oewn:2021', lemmatizer=Morphy())  # with lemmatization
>>> en.words('leaves')
[Word('oewn-leaf-n'), Word('oewn-leave-n'), Word('oewn-leaf-n'), Word('oewn-leave-n'), Word('oewn-leave-v')]
```

Perhaps it is returning each once for the lemmatized result and another for the alternative form?
Documentation of the various English wordnets
In the upcoming release there are now 5 English wordnet projects:

* `oewn`
* `ewn` (2 versions)
* `omw-en`
* `omw-en31`
* `own-en`

These will need some explanation so users know which one to get. In addition there may be some issues, publications, or documentation using the `pwn` identifier, which was delisted in favor of `omw-en` and `omw-en31`.

Something like this:

> The **Open English Wordnet** (`oewn`) is recommended for most work with English data, as it is the most advanced and actively developed project. The **OMW English Wordnet based on WordNet 3.0** (`omw-en`) is recommended in conjunction with the other OMW wordnets, or when strict compatibility with the Princeton WordNet 3.0 is needed. Similarly, the **OMW English Wordnet based on WordNet 3.1**  is recommended when compatibility with the Princeton WordNet 3.1 is needed. The **Open Wordnet of English** (`own-en`) is recommended for use in conjunction with the **Open Wordnet of Portuguese** (`own-pt`).



Discussion / hints:

I would have been hesitant, initially, as this kind of operation is probably only useful for wordnet authors, but there have been many easy-to-detect-if-you-know-what-to-look-for kind of issues with some wordnets of late, so it makes sense to make the tests easy to run. It would be useful if each test could be run individually. It would also be useful if the tests could be run from the command line.

Here's an initial list of tests:
- Every lexical entry has at least 1 sense
- Every synset has at least 1 member
- Synsets should have the same POS as their members (but `s` on a synset implies `a` on the member)
- Synsets should have the same POS as their hypernyms
- Inverse relations are present (e.g., hypernym/hyponym)
- Each ILI is assigned to at most one synset in a lexicon

Some simple things are already checked by `wn.lmf` or when adding a lexicon to the database:
- POS values are within `n`, `v`, `a`, `r`, and `s` (currently a warning is issued when reading the XML file)
- IDs are unique (sqite3 raises an integrity error on the uniqueness violation)
- Relation names are valid (currently `wn.lmr` throws an error if not)

Some things are not checked at this level but should be:
- POS on Lemma should not include `s`

Feel free to add more checks.
@fcbond would this be better as an operation over WN-LMF files or in-database lexicons? The benefit of working on the XML files is that we can write the checks for things that the database would refuse to load, such as missing relation targets, repeated IDs, etc. The benefit of working with in-database lexicons is that the features of Wn become available for the checks and the whole lexicon wouldn't have to be held in memory at once. The runtimes are probably similar: in-memory would start faster, but in-file would (probably) be quicker to run.

I'm leaning slightly towards checking the files, but I welcome your thoughts.
I changed my mind a bit about some of the tests, as I think those that violate the DTD should be checked by `wn.lmf`. I've therefore implemented the following:

* general identifier checks
  - non-unique ID
* lexical entries and senses
  - lexical entry is missing a lemma (extensions don't require a lemma, but this can possibly be checked in `wn.lmf`)
  - lexical entry has no senses (doesn't violate the DTD, but a word with no declared senses seems pretty useless)
  - synset of sense is missing
  - redundant sense between lexical entry and synset
  - redundant lexical entry with the same lemma and synset
* ili
  - ILI is repeated across synsets
  - proposed ILI is missing a definition (when `ili="in"`, `<ILIDefinition>` should be required)
  - existing ILI has a spurious definition (assuming there should be no `<ILIDefinition>` when `ili="..."` where "..." is not "in")
* synset
  - not a synset for any lexical entry (again, this doesn't violate the DTD but seems wrong)
* relations
  - relation target is missing or invalid
  - relation type is invalid for the source and target
  - redundant relation between source and target
  - hypernym's part-of-speech is different than hyponym
  - reverse relation is missing

I could still use some more graph checks, like for unwanted cycles or transitively non-minimal taxonomies.

Currently this takes about 30 seconds, with ~20s going to reading the XML file, ~7s for running the tests, and ~3s for printing the results. It also takes about 600MB for a 100MB XML file.
This looks great, and I can see you are finding things in OdeNet.

I can add the cycle check, is it ok to add networkx as a dependency?

On Wed, Oct 13, 2021 at 8:42 AM Michael Wayne Goodman <
***@***.***> wrote:

> I changed my mind a bit about some of the tests, as I think those that
> violate the DTD should be checked by wn.lmf. I've therefore implemented
> the following:
>
>    - general identifier checks
>       - non-unique ID
>    - lexical entries and senses
>       - lexical entry is missing a lemma (extensions don't require a
>       lemma, but this can possibly be checked in wn.lmf)
>       - lexical entry has no senses (doesn't violate the DTD, but a word
>       with no declared senses seems pretty useless)
>       - synset of sense is missing
>       - redundant sense between lexical entry and synset
>       - redundant lexical entry with the same lemma and synset
>    - ili
>       - ILI is repeated across synsets
>       - proposed ILI is missing a definition (when ili="in",
>       <ILIDefinition> should be required)
>       - existing ILI has a spurious definition (assuming there should be
>       no <ILIDefinition> when ili="..." where "..." is not "in")
>    - synset
>       - not a synset for any lexical entry (again, this doesn't violate
>       the DTD but seems wrong)
>    - relations
>       - relation target is missing or invalid
>       - relation type is invalid for the source and target
>       - redundant relation between source and target
>       - hypernym's part-of-speech is different than hyponym
>       - reverse relation is missing
>
> I could still use some more graph checks, like for unwanted cycles or
> transitively non-minimal taxonomies.
>
> Currently this takes about 30 seconds, with ~20s going to reading the XML
> file, ~7s for running the tests, and ~3s for printing the results. It also
> takes about 600MB for a 100MB XML file.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/goodmami/wn/issues/143#issuecomment-941799671>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAIPZRXDKQ6HGL2KANIXL53UGTIC5ANCNFSM5FNILSLQ>
> .
> Triage notifications on the go with GitHub Mobile for iOS
> <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
> or Android
> <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
>
>


-- 
Francis Bond <http://www3.ntu.edu.sg/home/fcbond/>
Division of Linguistics and Multilingual Studies
Nanyang Technological University




Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c1a6cc..216a828 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,81 @@
 ## [Unreleased]
 
 
+## [v0.9.0]
+
+**Release date: 2021-11-17**
+
+### Added
+
+* `wn.constants.REVERSE_RELATIONS`
+* `wn.validate` module ([#143])
+* `validate` subcommand ([#143])
+* `wn.Lexicon.describe()` ([#144])
+* `wn.Wordnet.describe()` ([#144])
+* `wn.ConfigurationError`
+* `wn.ProjectError`
+
+### Fixed
+
+* WN-LMF 1.0 Syntactic Behaviours with no `senses` are now assigned to
+  all senses in the lexical entry. If a WN-LMF 1.1 lexicon extension
+  puts Syntactic Behaviour elements on lexical entries (which it
+  shouldn't) it will only be assigned to senses and external senses
+  listed.
+* `wn.Form` now always hashes like `str`, so things like
+  `set.__contains__` works as expected.
+* `wn.download()` raises an exception on bad responses ([#147]])
+* Avoid returning duplicate matches when a lemmatizer is used ([#154])
+
+### Removed
+
+* `wn.lmf.dump()` no longer has the `version` parameter
+
+### Changed
+
+* `wn.lmf.load()`
+  - returns a dictionary for the resource instead of a
+    list of lexicons, now including the WN-LMF version, as below:
+    ```python
+    {
+        'lmf_version': '...',
+        'lexicons': [...]
+    }
+    ```
+  - returned lexicons are modeled with Python lists and dicts instead
+    of custom classes ([#80])
+* `wn.lmf.scan_lexicons()` only returns info about present lexicons,
+  not element counts ([#113])
+* Improper configurations (e.g., invalid data directory, malformed
+  index) now raise a `wn.ConfigurationError`
+* Attempting to get an unknown project or version now raises
+  `wn.ProjectError` instead of `wn.Error` or `KeyError`
+* Projects and versions in the index now take an `error` key. Calling
+  `wn.config.get_project_info()` on such an entry will raise
+  `wn.ProjectError`. Such entries may not also specify a url. The
+  entry can still be viewed without triggering the error via
+  `wn.config.index`. ([#146])
+* Project versions in the index may specify multiple, space-separated
+  URLs on the url key. If one fails, the next will be attempted when
+  downloading. ([#142])
+* `wn.config.get_project_info()` now returns a `resource_urls` key
+  mapped to a list of URLs instead of `resource_url` mapped to a
+  single URL. ([#142])
+* `wn.config.get_cache_path()` now only accepts URL arguments
+* The `lexicon` parameter in many functions now allows glob patterns
+  like `omw-*:1.4` ([#155])
+
+### Index
+
+* Added `oewn:2021` new ID, previously `ewn` ([#152])
+* Added `own`, `own-pt`, and `own-en` ([#97])
+* Added `odenet:1.4`
+* Added `omw:1.4`, including `omw-en`, formerly `pwn:3.0` ([#152])
+* Added `omw-en31:1.4`, formerly `pwn:3.1` ([#152])
+* Removed `omw:1.3`, `pwn:3.0`, and `pwn:3.1` ([#152])
+* Added `kurdnet:1.0` ([#140])
+
+
 ## [v0.8.3]
 
 **Release date: 2021-11-03**
@@ -403,6 +478,7 @@ the https://github.com/nltk/wordnet/ code which had been effectively
 abandoned, but this is an entirely new codebase.
 
 
+[v0.9.0]: ../../releases/tag/v0.9.0
 [v0.8.3]: ../../releases/tag/v0.8.3
 [v0.8.2]: ../../releases/tag/v0.8.2
 [v0.8.1]: ../../releases/tag/v0.8.1
@@ -448,6 +524,7 @@ abandoned, but this is an entirely new codebase.
 [#77]: https://github.com/goodmami/wn/issues/77
 [#78]: https://github.com/goodmami/wn/issues/78
 [#79]: https://github.com/goodmami/wn/issues/79
+[#80]: https://github.com/goodmami/wn/issues/80
 [#81]: https://github.com/goodmami/wn/issues/81
 [#82]: https://github.com/goodmami/wn/issues/82
 [#83]: https://github.com/goodmami/wn/issues/83
@@ -459,11 +536,13 @@ abandoned, but this is an entirely new codebase.
 [#92]: https://github.com/goodmami/wn/issues/92
 [#93]: https://github.com/goodmami/wn/issues/93
 [#95]: https://github.com/goodmami/wn/issues/95
+[#97]: https://github.com/goodmami/wn/issues/97
 [#99]: https://github.com/goodmami/wn/issues/99
 [#104]: https://github.com/goodmami/wn/issues/104
 [#105]: https://github.com/goodmami/wn/issues/105
 [#106]: https://github.com/goodmami/wn/issues/106
 [#108]: https://github.com/goodmami/wn/issues/108
+[#113]: https://github.com/goodmami/wn/issues/113
 [#115]: https://github.com/goodmami/wn/issues/115
 [#116]: https://github.com/goodmami/wn/issues/116
 [#117]: https://github.com/goodmami/wn/issues/117
@@ -472,4 +551,13 @@ abandoned, but this is an entirely new codebase.
 [#123]: https://github.com/goodmami/wn/issues/123
 [#124]: https://github.com/goodmami/wn/issues/124
 [#125]: https://github.com/goodmami/wn/issues/125
+[#140]: https://github.com/goodmami/wn/issues/140
+[#142]: https://github.com/goodmami/wn/issues/142
+[#143]: https://github.com/goodmami/wn/issues/143
+[#144]: https://github.com/goodmami/wn/issues/144
+[#146]: https://github.com/goodmami/wn/issues/146
+[#147]: https://github.com/goodmami/wn/issues/147
 [#148]: https://github.com/goodmami/wn/issues/148
+[#152]: https://github.com/goodmami/wn/issues/152
+[#154]: https://github.com/goodmami/wn/issues/154
+[#155]: https://github.com/goodmami/wn/issues/155
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..c27022c
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,38 @@
+cff-version: 1.2.0
+title: Wn
+message: >-
+  Please cite this software using the metadata from
+  'preferred-citation'.
+type: software
+authors:
+  - given-names: Michael Wayne
+    family-names: Goodman
+    email: goodman.m.w@gmail.com
+    orcid: 'https://orcid.org/0000-0002-2896-5141'
+  - given-names: Francis
+    family-names: Bond
+    email: bond@ieee.org
+    orcid: 'https://orcid.org/0000-0003-4973-8068'
+repository-code: 'https://github.com/goodmami/wn/'
+preferred-citation:
+  type: conference-paper
+  authors:
+  - family-names: Michael Wayne
+    given-names: Goodman
+    email: goodmami@uw.edu
+    orcid: 'https://orcid.org/0000-0002-2896-5141'
+    affiliation: Nanyang Technological University
+  - given-names: Francis
+    family-names: Bond
+    email: bond@ieee.org
+    orcid: 'https://orcid.org/0000-0003-4973-8068'
+    affiliation: Nanyang Technological University
+  start: 100  # First page number
+  end: 107  # Last page number
+  conference:
+      name: "Proceedings of the 11th Global Wordnet Conference"
+  title: "Intrinsically Interlingual: The Wn Python Library for Wordnets"
+  year: 2021
+  month: 1
+  url: 'https://aclanthology.org/2021.gwc-1.12/'
+  publisher: "Global Wordnet Association"
diff --git a/README.md b/README.md
index 59821cb..1d9d2c3 100644
--- a/README.md
+++ b/README.md
@@ -38,77 +38,164 @@ Added ewn:2020 (English WordNet)
 'be the winner in a contest or competition; be victorious'
 ```
 
-Unlike previous implementations, Wn uses a SQLite database to store
-wordnet data, which can make it much faster: Wn is 5x faster than the
-NLTK to list all English synsets, and almost 20x faster if you include
-the startup time. Some operations, particularly path operations that
-require multiple SQL queries, may be slower.
 
-Wn is also multilingual from the start. English is not the
-default. Instead, all wordnets are searched unless one (or more) are
-specified:
+## Features
 
-```python
->>> import wn
->>> wn.synsets('chat')                      # all installed wordnets
->>> wn.synsets('chat', lang='en')           # limit to one language
->>> wn.synsets('chat', lexicon='ewn:2020')  # limit to one wordnet
-```
+- Multilingual by design; first-class support for wordnets in any language
+- Interlingual queries via the [Collaborative Interlingual Index](https://github.com/globalwordnet/cili/)
+- Six [similarity metrics](https://wn.readthedocs.io/en/latest/api/wn.similarity.html)
+- Functions for [exploring taxonomies](https://wn.readthedocs.io/en/latest/api/wn.taxonomy.html)
+- Support for [lemmatization] ([Morphy] for English is built-in) and unicode [normalization]
+- Full support of the [WN-LMF 1.1](https://globalwordnet.github.io/schemas/) format, including word pronunciations and lexicon extensions
+- SQL-based backend offers very fast startup and improved performance on many kinds of queries
 
-## Available Wordnets
+[lemmatization]: https://wn.readthedocs.io/en/latest/guides/lemmatization.html#lemmatization
+[normalization]: https://wn.readthedocs.io/en/latest/guides/lemmatization.html#normalization
+[Morphy]: https://wn.readthedocs.io/en/latest/api/wn.morphy.html
 
-The following wordnets are indexed by Wn and ready to be installed:
 
-| Name                        | ID       | Versions       | Language         |
-| --------------------------- | -------- | -------------- | ---------------- |
-| [Open English WordNet]      | `ewn`    | `2019`, `2020` | English [en]     |
-| [Princeton WordNet]         | `pwn`    | `3.0`, `3.1`   | English [en]     |
-| [Open Multilingual Wordnet] | `omw`    | `1.3`          | multiple [[mul]] |
-| [Open German WordNet]       | `odenet` | `1.3`          | German [de]      |
+## Available Wordnets
+
+Any WN-LMF-formatted wordnet can be added to Wn's database from a local
+file or remote URL, but Wn also maintains an index (see
+[wn/index.toml](https://github.com/goodmami/wn/blob/main/wn/index.toml))
+of available projects, similar to a package manager for software, to aid
+in the discovery and downloading of new wordnets. The projects in this
+index are listed below.
+
+### English Wordnets
+
+There are several English wordnets available. In general it is
+recommended to use the latest [Open English Wordnet], but if you have
+stricter compatibility needs for, e.g., experiment replicability, you
+may try the [OMW English Wordnet based on WordNet 3.0] (compatible with
+the Princeton WordNet 3.0 and with the [NLTK]), or [OpenWordnet-EN] (for
+use with the Portuguese wordnet [OpenWordnet-PT]).
+
+| Name                                       | Specifier              | # Synsets | Notes |
+| ------------------------------------------ | ---------------------- | --------: | ----- |
+| [Open English WordNet] | `oewn:2021`<br/> `ewn:2020`<br/> `ewn:2019` | 120039<br/>120053<br/>117791 | Recommended<br/>&nbsp;<br/>&nbsp; |
+| [OMW English Wordnet based on WordNet 3.0] | `omw-en:1.4` | 117659 | Included with `omw:1.4` |
+| [OMW English Wordnet based on WordNet 3.1] | `omw-en31:1.4` | 117791 |  |
+| [OpenWordnet-EN] | `own-en:1.0.0` | 117659 | Included with `own:1.0.0` |
+
+[Open English WordNet]: https://en-word.net
+[Open Multilingual Wordnet]: https://github.com/omwn
+[OMW English Wordnet based on WordNet 3.0]: https://github.com/omwn/omw-data
+[OMW English Wordnet based on WordNet 3.1]: https://github.com/omwn/omw-data
+[OpenWordnet-EN]: https://github.com/own-pt/openWordnet-PT
+[OpenWordnet-PT]: https://github.com/own-pt/openWordnet-PT
+[NLTK]: https://www.nltk.org/
+
+### Other Wordnets and Collections
+
+These are standalone non-English wordnets and collections. The wordnets
+of each collection are listed further down.
+
+| Name                                       | Specifier                     | # Synsets       | Language         |
+| ------------------------------------------ | ----------------------------- | --------------: | ---------------- |
+| [Open Multilingual Wordnet]                | `omw:1.4`                     | n/a             | multiple [[mul]] |
+| [Open German WordNet]                      | `odenet:1.4`<br/>`odenet:1.3` | 36268<br/>36159 | German [de]      |
+| [Open Wordnets for Portuguese and English] | `own:1.0.0`                   | n/a             | multiple [[mul]] |
+| [KurdNet]                                  | `kurdnet:1.0`                 |            2144 | Kurdish [ckb]    |
 
 [Open English WordNet]: https://github.com/globalwordnet/english-wordnet
-[Princeton WordNet]: https://wordnet.princeton.edu/
-[Open Multilingual Wordnet]: https://lr.soh.ntu.edu.sg/omw/omw
+[Open Multilingual Wordnet]: https://github.com/omwn
+[OMW English Wordnet based on WordNet 3.0]: https://github.com/omwn
+[OMW English Wordnet based on WordNet 3.1]: https://github.com/omwn
 [Open German WordNet]: https://github.com/hdaSprachtechnologie/odenet
+[Open Wordnets for Portuguese and English]: https://github.com/own-pt
 [mul]: https://iso639-3.sil.org/code/mul
-
-The Open Multilingual Wordnet installs the following lexicons (from
-[here](https://github.com/bond-lab/omw-data/releases/tag/v1.3)) which
-can also be downloaded and installed independently:
-
-| Name                             | ID      | Versions   | Language                   |
-| -------------------------------- | ------- | ---------- | -------------------------- |
-| Albanet                          | `alswn` | `1.3+omw`  | Albanian [als]             |
-| Arabic WordNet (AWN v2)          | `arbwn` | `1.3+omw`  | Arabic [arb]               |
-| BulTreeBank Wordnet (BTB-WN)     | `bulwn` | `1.3+omw`  | Bulgarian [bg]             |
-| Chinese Open Wordnet             | `cmnwn` | `1.3+omw`  | Mandarin (Simplified) [zh] |
-| Croatian Wordnet                 | `hrvwn` | `1.3+omw`  | Croatian [hr]              |
-| DanNet                           | `danwn` | `1.3+omw`  | Danish [da]                |
-| FinnWordNet                      | `finwn` | `1.3+omw`  | Finnish [fi]               |
-| Greek Wordnet                    | `ellwn` | `1.3+omw`  | Greek [el]                 |
-| Hebrew Wordnet                   | `hebwn` | `1.3+omw`  | Hebrew [he]                |
-| IceWordNet                       | `islwn` | `1.3+omw`  | Icelandic [is]             |
-| Italian Wordnet                  | `iwn`   | `1.3+omw`  | Italian [it]               |
-| Japanese Wordnet                 | `jpnwn` | `1.3+omw`  | Japanese [jp]              |
-| Lithuanian  WordNet              | `litwn` | `1.3+omw`  | Lithuanian [lt]            |
-| Multilingual Central Repository  | `catwn` | `1.3+omw`  | Catalan [ca]               |
-| Multilingual Central Repository  | `euswn` | `1.3+omw`  | Basque [eu]                |
-| Multilingual Central Repository  | `glgwn` | `1.3+omw`  | Galician [gl]              |
-| Multilingual Central Repository  | `spawn` | `1.3+omw`  | Spanish [es]               |
-| MultiWordNet                     | `itawn` | `1.3+omw`  | Italian [it]               |
-| Norwegian Wordnet                | `nobwn` | `1.3+omw`  | Norwegian (Bokmål) [nb]    |
-| Norwegian Wordnet                | `nnown` | `1.3+omw`  | Norwegian (Nynorsk) [nn]   |
-| Open Dutch WordNet               | `nldwn` | `1.3+omw`  | Dutch [nl]                 |
-| OpenWN-PT                        | `porwn` | `1.3+omw`  | Portuguese [pt]            |
-| plWordNet                        | `polwn` | `1.3+omw`  | Polish [pl]                |
-| Romanian Wordnet                 | `ronwn` | `1.3+omw`  | Romanian [ro]              |
-| Slovak WordNet                   | `slkwn` | `1.3+omw`  | Slovak [sk]                |
-| sloWNet                          | `slvwn` | `1.3+omw`  | Slovenian [sl]             |
-| Swedish (SALDO)                  | `swewn` | `1.3+omw`  | Swedish [sv]               |
-| Thai Wordnet                     | `thawn` | `1.3+omw`  | Thai [th]                  |
-| WOLF (Wordnet Libre du Français) | `frawn` | `1.3+omw`  | French [fr]                |
-| Wordnet Bahasa                   | `indwn` | `1.3+omw`  | Indonesian [id]            |
-| Wordnet Bahasa                   | `zsmwn` | `1.3+omw`  | Malaysian [zsm]            |
-
-The project index list is defined in [wn/index.toml](https://github.com/goodmami/wn/blob/main/wn/index.toml).
-
+[KurdNet]: https://sinaahmadi.github.io/resources/kurdnet.html
+
+### Open Multilingual Wordnet (OMW) Collection
+
+The *Open Multilingual Wordnet* collection (`omw:1.4`) installs the
+following lexicons (from
+[here](https://github.com/omwn/omw-data/releases/tag/v1.4)) which can
+also be downloaded and installed independently:
+
+| Name                                     | Specifier      | # Synsets | Language                         |
+| ---------------------------------------- | -------------- | --------: | -------------------------------- |
+| Albanet                                  | `omw-sq:1.4`   |      4675 | Albanian [sq]                    |
+| Arabic WordNet (AWN v2)                  | `omw-arb:1.4`  |      9916 | Arabic [arb]                     |
+| BulTreeBank Wordnet (BTB-WN)             | `omw-bg:1.4`   |      4959 | Bulgarian [bg]                   |
+| Chinese Open Wordnet                     | `omw-cmn:1.4`  |     42312 | Mandarin (Simplified) [cmn-Hans] |
+| Croatian Wordnet                         | `omw-hr:1.4`   |     23120 | Croatian [hr]                    |
+| DanNet                                   | `omw-da:1.4`   |      4476 | Danish [da]                      |
+| FinnWordNet                              | `omw-fi:1.4`   |    116763 | Finnish [fi]                     |
+| Greek Wordnet                            | `omw-el:1.4`   |     18049 | Greek [el]                       |
+| Hebrew Wordnet                           | `omw-he:1.4`   |      5448 | Hebrew [he]                      |
+| IceWordNet                               | `omw-is:1.4`   |      4951 | Icelandic [is]                   |
+| Italian Wordnet                          | `omw-iwn:1.4`  |     15563 | Italian [it]                     |
+| Japanese Wordnet                         | `omw-ja:1.4`   |     57184 | Japanese [ja]                    |
+| Lithuanian  WordNet                      | `omw-lt:1.4`   |      9462 | Lithuanian [lt]                  |
+| Multilingual Central Repository          | `omw-ca:1.4`   |     45826 | Catalan [ca]                     |
+| Multilingual Central Repository          | `omw-eu:1.4`   |     29413 | Basque [eu]                      |
+| Multilingual Central Repository          | `omw-gl:1.4`   |     19312 | Galician [gl]                    |
+| Multilingual Central Repository          | `omw-es:1.4`   |     38512 | Spanish [es]                     |
+| MultiWordNet                             | `omw-it:1.4`   |     35001 | Italian [it]                     |
+| Norwegian Wordnet                        | `omw-nb:1.4`   |      4455 | Norwegian (Bokmål) [nb]          |
+| Norwegian Wordnet                        | `omw-nn:1.4`   |      3671 | Norwegian (Nynorsk) [nn]         |
+| OMW English Wordnet based on WordNet 3.0 | `omw-en:1.4`   |    117659 | English [en]                     |
+| Open Dutch WordNet                       | `omw-nl:1.4`   |     30177 | Dutch [nl]                       |
+| OpenWN-PT                                | `omw-pt:1.4`   |     43895 | Portuguese [pt]                  |
+| plWordNet                                | `omw-pl:1.4`   |     33826 | Polish [pl]                      |
+| Romanian Wordnet                         | `omw-ro:1.4`   |     56026 | Romanian [ro]                    |
+| Slovak WordNet                           | `omw-sk:1.4`   |     18507 | Slovak [sk]                      |
+| sloWNet                                  | `omw-sl:1.4`   |     42583 | Slovenian [sl]                   |
+| Swedish (SALDO)                          | `omw-sv:1.4`   |      6796 | Swedish [sv]                     |
+| Thai Wordnet                             | `omw-th:1.4`   |     73350 | Thai [th]                        |
+| WOLF (Wordnet Libre du Français)         | `omw-fr:1.4`   |     59091 | French [fr]                      |
+| Wordnet Bahasa                           | `omw-id:1.4`   |     38085 | Indonesian [id]                  |
+| Wordnet Bahasa                           | `omw-zsm:1.4`  |     36911 | Malaysian [zsm]                  |
+
+### Open Wordnet (OWN) Collection
+
+The *Open Wordnets for Portuguese and English* collection (`own:1.0.0`)
+installs the following lexicons (from
+[here](https://github.com/own-pt/openWordnet-PT/releases/tag/v1.0.0))
+which can also be downloaded and installed independently:
+
+| Name           | Specifier      | # Synsets | Language        |
+| -------------- | -------------- | --------: | --------------- |
+| OpenWordnet-PT | `own-pt:1.0.0` |     52670 | Portuguese [pt] |
+| OpenWordnet-EN | `own-en:1.0.0` |    117659 | English [en]    |
+
+### Collaborative Interlingual Index
+
+While not a wordnet, the [Collaborative Interlingual Index] (CILI)
+represents the interlingual backbone of many wordnets. Wn, including
+interlingual queries, will function without CILI loaded, but adding it
+to the database makes available the full list of concepts, their status
+(active, deprecated, etc.), and their definitions.
+
+| Name                               | Specifier  | # Concepts |
+| ---------------------------------- | ---------- | ---------: |
+| [Collaborative Interlingual Index] | `cili:1.0` |     117659 |
+
+[Collaborative Interlingual Index]: https://github.com/globalwordnet/cili/
+
+
+## Changes to the Index
+
+### `ewn` → `oewn`
+
+The 2021 version of the *Open English WordNet* (`oewn:2021`) has
+changed its lexicon ID from `ewn` to `oewn`, so the index is updated
+accordingly. The previous versions are still available as `ewn:2019`
+and `ewn:2020`.
+
+### `pwn` → `omw-en`, `omw-en31`
+
+The wordnet formerly called the *Princeton WordNet* (`pwn:3.0`,
+`pwn:3.1`) is now called the *OMW English Wordnet based on WordNet
+3.0* (`omw-en`) and the *OMW English Wordnet based on WordNet 3.1*
+(`omw-en31`). This is more accurate, as it is a OMW-produced
+derivative of the original WordNet data, and it also avoids license or
+trademark issues.
+
+### `*wn` → `omw-*` for OMW wordnets
+
+All OMW wordnets have changed their ID scheme from `...wn` to `omw-..` and the version no longer
+includes `+omw` (e.g., `bulwn:1.3+omw` is now `omw-bg:1.4`).
diff --git a/docs/_static/demo.ipynb b/docs/_static/demo.ipynb
index b4a7565..5b70ba2 100644
--- a/docs/_static/demo.ipynb
+++ b/docs/_static/demo.ipynb
@@ -4,7 +4,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "![logo](wn-logo-rotate.svg)\n",
+    "![logo](https://raw.githubusercontent.com/goodmami/wn/main/docs/_static/wn-logo-rotate.svg)\n",
     "\n",
     "# Wn Demonstration\n",
     "\n",
@@ -14,14 +14,14 @@
     "\n",
     "  ```console\n",
     "  $ python3 -m pip install wn jupyter\n",
-    "  $ python3 -m wn download pwn:3.0 omw ewn:2020\n",
+    "  $ python3 -m wn download omw oewn:2021\n",
     "  ```\n",
     "  \n",
     "* Windows\n",
     "\n",
     "  ```console\n",
     "  > py -3 -m pip install wn jupyter\n",
-    "  > py -3 -m wn download pwn:3.0 omw ewn:2020\n",
+    "  > py -3 -m wn download omw oewn:2021\n",
     "  ```\n",
     "\n",
     "Now you should be able to import the `wn` package:"
@@ -53,7 +53,7 @@
     {
      "data": {
       "text/plain": [
-       "[Word('ewn-Malacca-n')]"
+       "[Word('oewn-Malacca-n')]"
       ]
      },
      "execution_count": 2,
@@ -73,7 +73,7 @@
     {
      "data": {
       "text/plain": [
-       "[Synset('ewn-08985168-n')]"
+       "[Synset('oewn-08985168-n')]"
       ]
      },
      "execution_count": 3,
@@ -102,18 +102,19 @@
     {
      "data": {
       "text/plain": [
-       "[Word('pwn-idea-n'),\n",
-       " Word('finwn-lex50473'),\n",
-       " Word('iwn-lex23768'),\n",
-       " Word('slkwn-lex16202'),\n",
-       " Word('zsmwn-lex71794'),\n",
-       " Word('glgwn-lex1079'),\n",
-       " Word('euswn-lex10715'),\n",
-       " Word('spawn-lex29439'),\n",
-       " Word('itawn-lex15594'),\n",
-       " Word('polwn-lex24963'),\n",
-       " Word('catwn-lex37718'),\n",
-       " Word('ewn-idea-n')]"
+       "[Word('omw-en-idea-n'),\n",
+       " Word('omw-sk-idea-n'),\n",
+       " Word('omw-pl-idea-n'),\n",
+       " Word('omw-is-ídea-n'),\n",
+       " Word('omw-zsm-idea-n'),\n",
+       " Word('omw-iwn-idea-n'),\n",
+       " Word('omw-it-idea-n'),\n",
+       " Word('omw-gl-idea-n'),\n",
+       " Word('omw-fi-idea-n'),\n",
+       " Word('omw-ca-idea-n'),\n",
+       " Word('omw-eu-idea-n'),\n",
+       " Word('omw-es-idea-n'),\n",
+       " Word('oewn-idea-n')]"
       ]
      },
      "execution_count": 4,
@@ -129,7 +130,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "You can filter down the results by language, but that may not be enough if you have multiple wordnets for the same language (e.g., the [Princeton WordNet](https://wordnet.princeton.edu/) of English and the [English WordNet](https://en-word.net/)):"
+    "You can filter down the results by language, but that may not be enough if you have multiple wordnets for the same language (e.g., the [OMW English Wordnet based on WordNet 3.0](https://github.com/omwn/omw-data/) and the [Open English WordNet](https://en-word.net/)):"
    ]
   },
   {
@@ -140,7 +141,7 @@
     {
      "data": {
       "text/plain": [
-       "[Word('pwn-idea-n'), Word('ewn-idea-n')]"
+       "[Word('omw-en-idea-n'), Word('oewn-idea-n')]"
       ]
      },
      "execution_count": 5,
@@ -167,7 +168,7 @@
     {
      "data": {
       "text/plain": [
-       "(<Lexicon pwn:3.0 [en]>, <Lexicon ewn:2020 [en]>)"
+       "[<Lexicon omw-en:1.4 [en]>, <Lexicon oewn:2021 [en]>]"
       ]
      },
      "execution_count": 6,
@@ -194,7 +195,7 @@
     {
      "data": {
       "text/plain": [
-       "[Word('pwn-idea-n')]"
+       "[Word('omw-en-idea-n')]"
       ]
      },
      "execution_count": 7,
@@ -203,7 +204,7 @@
     }
    ],
    "source": [
-    "wn.words('idea', lexicon='pwn:3.0')"
+    "wn.words('idea', lexicon='omw-en:1.4')"
    ]
   },
   {
@@ -221,7 +222,7 @@
     {
      "data": {
       "text/plain": [
-       "[Word('pwn-idea-n')]"
+       "[Word('omw-en-idea-n')]"
       ]
      },
      "execution_count": 8,
@@ -230,8 +231,8 @@
     }
    ],
    "source": [
-    "pwn = wn.Wordnet(lexicon='pwn:3.0')\n",
-    "pwn.words('idea')"
+    "en = wn.Wordnet(lexicon='omw-en:1.4')\n",
+    "en.words('idea')"
    ]
   },
   {
@@ -251,7 +252,7 @@
     {
      "data": {
       "text/plain": [
-       "[Word('pwn-pencil-n'), Word('pwn-pencil-v')]"
+       "[Word('omw-en-pencil-n'), Word('omw-en-pencil-v')]"
       ]
      },
      "execution_count": 9,
@@ -260,7 +261,7 @@
     }
    ],
    "source": [
-    "pwn.words('pencil')"
+    "en.words('pencil')"
    ]
   },
   {
@@ -278,7 +279,7 @@
     {
      "data": {
       "text/plain": [
-       "[Word('pwn-pencil-v')]"
+       "[Word('omw-en-pencil-v')]"
       ]
      },
      "execution_count": 10,
@@ -287,7 +288,7 @@
     }
    ],
    "source": [
-    "pwn.words('pencil', pos='v')"
+    "en.words('pencil', pos='v')"
    ]
   },
   {
@@ -305,11 +306,11 @@
     {
      "data": {
       "text/plain": [
-       "[Sense('pwn-pencil-n-hs-03908204'),\n",
-       " Sense('pwn-pencil-n-hs-03908456'),\n",
-       " Sense('pwn-pencil-n-hs-13863020'),\n",
-       " Sense('pwn-pencil-n-hs-14796748'),\n",
-       " Sense('pwn-pencil-v-hs-01688604')]"
+       "[Sense('omw-en-pencil-03908204-n'),\n",
+       " Sense('omw-en-pencil-14796748-n'),\n",
+       " Sense('omw-en-pencil-13863020-n'),\n",
+       " Sense('omw-en-pencil-03908456-n'),\n",
+       " Sense('omw-en-pencil-01688604-v')]"
       ]
      },
      "execution_count": 11,
@@ -318,7 +319,7 @@
     }
    ],
    "source": [
-    "pwn.senses('pencil')"
+    "en.senses('pencil')"
    ]
   },
   {
@@ -329,7 +330,7 @@
     {
      "data": {
       "text/plain": [
-       "[Sense('pwn-pencil-v-hs-01688604')]"
+       "[Sense('omw-en-pencil-01688604-v')]"
       ]
      },
      "execution_count": 12,
@@ -338,7 +339,7 @@
     }
    ],
    "source": [
-    "pwn.senses('pencil', pos='v')"
+    "en.senses('pencil', pos='v')"
    ]
   },
   {
@@ -349,7 +350,7 @@
     {
      "data": {
       "text/plain": [
-       "[Synset('pwn-01688604-v')]"
+       "[Synset('omw-en-01688604-v')]"
       ]
      },
      "execution_count": 13,
@@ -358,7 +359,7 @@
     }
    ],
    "source": [
-    "pwn.synsets('pencil', pos='v')"
+    "en.synsets('pencil', pos='v')"
    ]
   },
   {
@@ -385,7 +386,7 @@
     }
    ],
    "source": [
-    "len(pwn.words(pos='v'))"
+    "len(en.words(pos='v'))"
    ]
   },
   {
@@ -403,7 +404,7 @@
     {
      "data": {
       "text/plain": [
-       "158828"
+       "156584"
       ]
      },
      "execution_count": 15,
@@ -412,7 +413,7 @@
     }
    ],
    "source": [
-    "len(pwn.words())"
+    "len(en.words())"
    ]
   },
   {
@@ -441,7 +442,7 @@
     }
    ],
    "source": [
-    "pencil = pwn.words('pencil', pos='v')[0]\n",
+    "pencil = en.words('pencil', pos='v')[0]\n",
     "pencil.lemma()"
    ]
   },
@@ -493,7 +494,7 @@
     {
      "data": {
       "text/plain": [
-       "[Sense('pwn-pencil-v-hs-01688604')]"
+       "[Sense('omw-en-pencil-01688604-v')]"
       ]
      },
      "execution_count": 19,
@@ -513,7 +514,7 @@
     {
      "data": {
       "text/plain": [
-       "Synset('pwn-01688604-v')"
+       "Synset('omw-en-01688604-v')"
       ]
      },
      "execution_count": 20,
@@ -533,7 +534,7 @@
     {
      "data": {
       "text/plain": [
-       "[Synset('pwn-01688604-v')]"
+       "[Synset('omw-en-01688604-v')]"
       ]
      },
      "execution_count": 21,
@@ -553,7 +554,7 @@
     {
      "data": {
       "text/plain": [
-       "'write, draw, or trace with a pencil; \"he penciled a figure\"'"
+       "'write, draw, or trace with a pencil'"
       ]
      },
      "execution_count": 22,
@@ -574,7 +575,7 @@
     {
      "data": {
       "text/plain": [
-       "[]"
+       "['he penciled a figure']"
       ]
      },
      "execution_count": 23,
@@ -583,7 +584,7 @@
     }
    ],
    "source": [
-    "pencil_ss.examples()  # depends on the wordnet data; see below"
+    "pencil_ss.examples()"
    ]
   },
   {
@@ -594,7 +595,7 @@
     {
      "data": {
       "text/plain": [
-       "['\"he penciled a figure\"']"
+       "[Synset('omw-en-01690294-v')]"
       ]
      },
      "execution_count": 24,
@@ -602,33 +603,13 @@
      "output_type": "execute_result"
     }
    ],
-   "source": [
-    "wn.synsets('pencil', pos='v', lexicon='ewn:2020')[0].examples()  # EWN splits examples from definitions"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 25,
-   "metadata": {},
-   "outputs": [
-    {
-     "data": {
-      "text/plain": [
-       "[Synset('pwn-01690294-v')]"
-      ]
-     },
-     "execution_count": 25,
-     "metadata": {},
-     "output_type": "execute_result"
-    }
-   ],
    "source": [
     "pencil_ss.hypernyms()"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 26,
+   "execution_count": 25,
    "metadata": {},
    "outputs": [
     {
@@ -637,7 +618,7 @@
        "['draw']"
       ]
      },
-     "execution_count": 26,
+     "execution_count": 25,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -657,17 +638,17 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 27,
+   "execution_count": 26,
    "metadata": {},
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      " Synset('pwn-01690294-v') ['draw']\n",
-      "   Synset('pwn-01686132-v') ['represent', 'interpret']\n",
-      "     Synset('pwn-01619354-v') ['re-create']\n",
-      "       Synset('pwn-01617192-v') ['make', 'create']\n"
+      " Synset('omw-en-01690294-v') ['draw']\n",
+      "   Synset('omw-en-01686132-v') ['represent', 'interpret']\n",
+      "     Synset('omw-en-01619354-v') ['re-create']\n",
+      "       Synset('omw-en-01617192-v') ['make', 'create']\n"
      ]
     }
    ],
@@ -686,7 +667,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 28,
+   "execution_count": 27,
    "metadata": {},
    "outputs": [
     {
@@ -695,19 +676,19 @@
        "2"
       ]
      },
-     "execution_count": 28,
+     "execution_count": 27,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "dog = pwn.synsets('dog', pos='n')[0]\n",
+    "dog = en.synsets('dog', pos='n')[0]\n",
     "len(dog.hypernym_paths())  # two paths"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 29,
+   "execution_count": 28,
    "metadata": {},
    "outputs": [
     {
@@ -716,7 +697,7 @@
        "(8, 13)"
       ]
      },
-     "execution_count": 29,
+     "execution_count": 28,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -734,20 +715,20 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 30,
+   "execution_count": 29,
    "metadata": {},
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      " Synset('pwn-01697816-v') ['create verbally']\n",
-      "   Synset('pwn-01617192-v') ['make', 'create']\n"
+      " Synset('omw-en-01697816-v') ['create verbally']\n",
+      "   Synset('omw-en-01617192-v') ['make', 'create']\n"
      ]
     }
    ],
    "source": [
-    "pen_ss = pwn.synsets('pen', pos='v')[0]\n",
+    "pen_ss = en.synsets('pen', pos='v')[0]\n",
     "for path in pen_ss.hypernym_paths():\n",
     "    for i, ss in enumerate(path):\n",
     "        print('  ' * i, ss, ss.lemmas())"
@@ -755,16 +736,16 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 31,
+   "execution_count": 30,
    "metadata": {},
    "outputs": [
     {
      "data": {
       "text/plain": [
-       "[Synset('pwn-01617192-v')]"
+       "[Synset('omw-en-01617192-v')]"
       ]
      },
-     "execution_count": 31,
+     "execution_count": 30,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -775,19 +756,19 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 32,
+   "execution_count": 31,
    "metadata": {},
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "Synset('pwn-01690294-v') ['draw']\n",
-      "Synset('pwn-01686132-v') ['represent', 'interpret']\n",
-      "Synset('pwn-01619354-v') ['re-create']\n",
-      "Synset('pwn-01617192-v') ['make', 'create']\n",
-      "Synset('pwn-01697816-v') ['create verbally']\n",
-      "Synset('pwn-01698271-v') ['write', 'indite', 'compose', 'pen']\n"
+      "Synset('omw-en-01690294-v') ['draw']\n",
+      "Synset('omw-en-01686132-v') ['represent', 'interpret']\n",
+      "Synset('omw-en-01619354-v') ['re-create']\n",
+      "Synset('omw-en-01617192-v') ['make', 'create']\n",
+      "Synset('omw-en-01697816-v') ['create verbally']\n",
+      "Synset('omw-en-01698271-v') ['write', 'compose', 'pen', 'indite']\n"
      ]
     }
    ],
@@ -807,7 +788,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 33,
+   "execution_count": 32,
    "metadata": {},
    "outputs": [
     {
@@ -816,13 +797,13 @@
        "'a thin cylindrical pointed writing implement; a rod of marking substance encased in wood'"
       ]
      },
-     "execution_count": 33,
+     "execution_count": 32,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "pencil_ss = pwn.synsets('pencil', pos='n')[0]  # for this we'll use the nominal sense\n",
+    "pencil_ss = en.synsets('pencil', pos='n')[0]  # for this we'll use the nominal sense\n",
     "pencil_ss.definition()"
    ]
   },
@@ -835,7 +816,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 34,
+   "execution_count": 33,
    "metadata": {},
    "outputs": [
     {
@@ -844,7 +825,7 @@
        "['lapis', 'matita']"
       ]
      },
-     "execution_count": 34,
+     "execution_count": 33,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -855,79 +836,79 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 35,
+   "execution_count": 34,
    "metadata": {},
    "outputs": [
     {
      "data": {
       "text/plain": [
-       "['鉛筆', 'ペンシル', '木筆']"
+       "['ペンシル', '木筆', '鉛筆']"
       ]
      },
-     "execution_count": 35,
+     "execution_count": 34,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "pencil_ss.translate(lexicon='jpnwn')[0].lemmas()"
+    "pencil_ss.translate(lexicon='omw-ja')[0].lemmas()"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 36,
+   "execution_count": 35,
    "metadata": {},
    "outputs": [
     {
      "data": {
       "text/plain": [
-       "{Sense('pwn-pencil-n-hs-03908204'): [Word('jpnwn-lex80679'),\n",
-       "  Word('jpnwn-lex82279'),\n",
-       "  Word('jpnwn-lex82280')],\n",
-       " Sense('pwn-pencil-n-hs-03908456'): [Word('jpnwn-lex82279')],\n",
-       " Sense('pwn-pencil-n-hs-13863020'): [],\n",
-       " Sense('pwn-pencil-n-hs-14796748'): [Word('jpnwn-lex80679')]}"
+       "{Sense('omw-en-pencil-03908204-n'): [Word('omw-ja-ペンシル-n'),\n",
+       "  Word('omw-ja-木筆-n'),\n",
+       "  Word('omw-ja-鉛筆-n')],\n",
+       " Sense('omw-en-pencil-14796748-n'): [Word('omw-ja-鉛筆-n')],\n",
+       " Sense('omw-en-pencil-13863020-n'): [],\n",
+       " Sense('omw-en-pencil-03908456-n'): [Word('omw-ja-ペンシル-n')]}"
       ]
      },
-     "execution_count": 36,
+     "execution_count": 35,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "pwn.words('pencil', pos='n')[0].translate(lexicon='jpnwn')"
+    "en.words('pencil', pos='n')[0].translate(lexicon='omw-ja')"
    ]
   },
   {
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Interlingual synsets are also used to traversing relations from another wordnet. For instance, many of the lexicons in the [Open Multilingual Wordnet](https://lr.soh.ntu.edu.sg/omw/omw) were created using the *expand* method where only words were translated on top of Princeton Wordnet synsets. All relations (hypernyms, hyponyms, etc.) then depend on those from the PWN. In Wn, a [Wordnet](https://wn.readthedocs.io/en/latest/api/wn.html#the-wordnet-class) object may be instantiated with an `expand` parameter which selects lexicons containing such relations. By default, all lexicons are used (i.e., `expand='*'`), but you can tell Wn to not use any expand lexicons (`expand=''`) or to use a specific lexicon (`expand='pwn:3.0'`). By being specific, you can better control the behaviour of your program, e.g., for experimental reproducibility."
+    "Interlingual synsets are also used to traversing relations from another wordnet. For instance, many of the lexicons in the [Open Multilingual Wordnet](https://github.com/omwn/omw-data) were created using the *expand* method where only words were translated on top of Princeton WordNet synsets. All relations (hypernyms, hyponyms, etc.) then depend on those from WordNet. In Wn, a [Wordnet](https://wn.readthedocs.io/en/latest/api/wn.html#the-wordnet-class) object may be instantiated with an `expand` parameter which selects lexicons containing such relations. By default, all lexicons are used (i.e., `expand='*'`), but you can tell Wn to not use any expand lexicons (`expand=''`) or to use a specific lexicon (`expand='omw-en:1.4'`). By being specific, you can better control the behaviour of your program, e.g., for experimental reproducibility."
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 37,
+   "execution_count": 36,
    "metadata": {},
    "outputs": [
     {
      "data": {
       "text/plain": [
-       "[Synset('jpnwn-04608567-n')]"
+       "[Synset('omw-ja-14796575-n')]"
       ]
      },
-     "execution_count": 37,
+     "execution_count": 36,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "wn.Wordnet(lexicon='jpnwn').synsets('鉛筆')[0].hypernyms()  # by default, any other installed lexicon may be used"
+    "wn.Wordnet(lexicon='omw-ja').synsets('鉛筆')[0].hypernyms()  # by default, any other installed lexicon may be used"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 38,
+   "execution_count": 37,
    "metadata": {},
    "outputs": [
     {
@@ -936,33 +917,33 @@
        "[]"
       ]
      },
-     "execution_count": 38,
+     "execution_count": 37,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "wn.Wordnet(lexicon='jpnwn', expand='').synsets('鉛筆')[0].hypernyms()  # disable interlingual query expansion"
+    "wn.Wordnet(lexicon='omw-ja', expand='').synsets('鉛筆')[0].hypernyms()  # disable interlingual query expansion"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 39,
+   "execution_count": 38,
    "metadata": {},
    "outputs": [
     {
      "data": {
       "text/plain": [
-       "[Synset('jpnwn-04608567-n')]"
+       "[Synset('omw-ja-14796575-n')]"
       ]
      },
-     "execution_count": 39,
+     "execution_count": 38,
      "metadata": {},
      "output_type": "execute_result"
     }
    ],
    "source": [
-    "wn.Wordnet(lexicon='jpnwn', expand='pwn:3.0').synsets('鉛筆')[0].hypernyms()  # specify the expand set"
+    "wn.Wordnet(lexicon='omw-ja', expand='omw-en:1.4').synsets('鉛筆')[0].hypernyms()  # specify the expand set"
    ]
   },
   {
@@ -975,7 +956,7 @@
  ],
  "metadata": {
   "kernelspec": {
-   "display_name": "Python 3",
+   "display_name": "Python 3 (ipykernel)",
    "language": "python",
    "name": "python3"
   },
@@ -989,7 +970,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.5"
+   "version": "3.9.5"
   }
  },
  "nbformat": 4,
diff --git a/docs/_static/wn-logo-rotate.svg b/docs/_static/wn-logo-rotate.svg
index 07f312b..3be817a 100644
--- a/docs/_static/wn-logo-rotate.svg
+++ b/docs/_static/wn-logo-rotate.svg
@@ -1,5 +1,15 @@
 <?xml version="1.0" standalone="no"?>
 <svg width="200" height="100" xmlns="http://www.w3.org/2000/svg" version="1.1">
+  <defs>
+    <linearGradient id="wgradient" gradientTransform="rotate(90)">
+      <stop offset="0%"   stop-color="#814F92" />
+      <stop offset="100%" stop-color="#41285C" />
+    </linearGradient>
+    <linearGradient id="ngradient" gradientTransform="rotate(90)">
+      <stop offset="0%"   stop-color="#3CD2CC" />
+      <stop offset="100%" stop-color="#176361" />
+    </linearGradient>
+  </defs>
   <style type="text/css"><![CDATA[
     .path {
       stroke-width: 10;
@@ -22,8 +32,8 @@
       95% { transform: rotate(0); }
       100% { transform: rotate(45deg); }
     }
-    #w { stroke: #671d5b; stroke-opacity: 1; animation: 20s rotate-45-clockwise 0s ease-in-out infinite; }
-    #n { stroke: #2656a6; stroke-opacity: 1; animation: 20s rotate-45-anticlockwise 0s ease-in-out infinite; }
+    #w { stroke: url(#wgradient); stroke-opacity: 1; animation: 20s rotate-45-clockwise 0s ease-in-out infinite; }
+    #n { stroke: url(#ngradient); stroke-opacity: 1; animation: 20s rotate-45-anticlockwise 0s ease-in-out infinite; }
   ]]></style>
   <path id="n" class="path" d="M100,90 L60,50 140,50 100,10" />
   <path id="w" class="path" d="M100,10 L60,50 100,50 100,90 140,50" />
diff --git a/docs/_static/wn-logo.svg b/docs/_static/wn-logo.svg
index 93cd47b..bfb83ad 100644
--- a/docs/_static/wn-logo.svg
+++ b/docs/_static/wn-logo.svg
@@ -1,15 +1,23 @@
 <?xml version="1.0" standalone="no"?>
-<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg" version="1.1">
+<svg width="150" height="150" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" version="1.1">
+  <defs>
+    <linearGradient id="wgradient" gradientTransform="rotate(90)">
+      <stop offset="0%"   stop-color="#814F92" />
+      <stop offset="100%" stop-color="#41285C" />
+    </linearGradient>
+    <linearGradient id="ngradient" gradientTransform="rotate(90)">
+      <stop offset="0%"   stop-color="#3CD2CC" />
+      <stop offset="100%" stop-color="#176361" />
+    </linearGradient>
+  </defs>
   <style type="text/css"><![CDATA[
     .path {
-      stroke-width: 10;
+      stroke-width: 1;
       fill: none;
       stroke-linecap: round;
       stroke-linejoin: round;
     }
-    #w { stroke: #671d5b; }
-    #n { stroke: #2656a6; }
-  ]]></style>
-  <path id="n" class="path" d="M50,90 L10,50 90,50 50,10" />
-  <path id="w" class="path" d="M50,10 L10,50 50,50 50,90 90,50" />
+    ]]></style>
+  <path id="n" stroke="url(#ngradient)" class="path" d="M5,9 L1,5 9,5 5,1" />
+  <path id="w" stroke="url(#wgradient)" class="path" d="M5,1 L1,5 5,5 5,9 9,5" />
 </svg>
diff --git a/docs/api/wn.ic.rst b/docs/api/wn.ic.rst
index ffb6e0e..2d699bc 100644
--- a/docs/api/wn.ic.rst
+++ b/docs/api/wn.ic.rst
@@ -73,17 +73,20 @@ the number of synsets for the word:
 Example
 -------
 
-In the Princeton WordNet, the frequency of a concept like **stone
-fruit** is not the number of occurrences of *stone fruit*, but also
-includes the counts of the words for its hyponyms (*almond*, *olive*,
-etc.) and other taxonomic descendants (*Jordan almond*, *green olive*,
-etc.). The word *almond* has two synsets: one for the fruit or nut,
-another for the plant. Thus, if the word *almond* is encountered
-:math:`n` times in a corpus, then the weight (either the frequency
-:math:`n` or distributed weight :math:`\frac{n}{2}`) is added to the
-total weights for both synsets and to those of their ancestors, but
-not for descendant synsets, such as for **Jordan almond**. The fruit/nut
-synset of almond has two hypernym paths which converge on **fruit**:
+In the Princeton WordNet 3.0 (hereafter *WordNet*, but note that the
+equivalent lexicon in Wn is the *OMW English Wordnet based on WordNet
+3.0* with specifier ``omw-en:1.4``), the frequency of a concept like
+**stone fruit** is not just the number of occurrences of *stone
+fruit*, but also includes the counts of the words for its hyponyms
+(*almond*, *olive*, etc.) and other taxonomic descendants (*Jordan
+almond*, *green olive*, etc.). The word *almond* has two synsets: one
+for the fruit or nut, another for the plant. Thus, if the word
+*almond* is encountered :math:`n` times in a corpus, then the weight
+(either the frequency :math:`n` or distributed weight
+:math:`\frac{n}{2}`) is added to the total weights for both synsets
+and to those of their ancestors, but not for descendant synsets, such
+as for **Jordan almond**. The fruit/nut synset of almond has two
+hypernym paths which converge on **fruit**:
 
 1. **almond** ⊃ **stone fruit** ⊃ **fruit**
 2. **almond** ⊃ **nut** ⊃ **seed** ⊃ **fruit**
@@ -110,8 +113,8 @@ The corpus is an iterable of words. For large corpora it may help to
 use a generator for this iterable, but the entire vocabulary (i.e.,
 unique words and counts) will be held at once in memory. Multi-word
 expressions are also possible if they exist in the wordnet. For
-instance, the Princeton WordNet has *stone fruit*, with a single space
-delimiting the words, as an entry.
+instance, WordNet has *stone fruit*, with a single space delimiting
+the words, as an entry.
 
 The :class:`wn.Wordnet` object must be instantiated with a single
 lexicon, although it may have expand-lexicons for relation
@@ -147,17 +150,16 @@ be given another callable with the same signature as shown below:
 .. warning::
 
    The weights files are only valid for the version of wordnet for
-   which they were created. Files created for the Princeton WordNet
-   3.0 do not work for the Princeton WordNet 3.1 because the offsets
-   used in its identifiers are different, although the *get_synset_id*
-   parameter of :func:`load` could be given a function that performs a
-   suitable mapping. Some `Open Multilingual Wordnet
-   <https://github.com/globalwordnet/OMW>`_ wordnets use the Princeton
-   WordNet 3.0 offsets in their identifiers and can therefore
-   technically use the weights, but this usage is discouraged because
-   the distributional properties of text in another language and the
-   structure of the other wordnet will not be compatible with that of
-   the Princeton WordNet. For these cases, it is recommended to
-   compute new weights using :func:`compute`.
+   which they were created. Files created for WordNet 3.0 do not work
+   for WordNet 3.1 because the offsets used in its identifiers are
+   different, although the *get_synset_id* parameter of :func:`load`
+   could be given a function that performs a suitable mapping. Some
+   `Open Multilingual Wordnet <https://github.com/omwn/omw-data>`_
+   wordnets use the WordNet 3.0 offsets in their identifiers and can
+   therefore technically use the weights, but this usage is
+   discouraged because the distributional properties of text in
+   another language and the structure of the other wordnet will not be
+   compatible with that of the English WordNet. For these cases, it is
+   recommended to compute new weights using :func:`compute`.
 
 .. autofunction:: load
diff --git a/docs/api/wn.rst b/docs/api/wn.rst
index 3a004ab..c8def1d 100644
--- a/docs/api/wn.rst
+++ b/docs/api/wn.rst
@@ -44,6 +44,7 @@ The Wordnet Class
    .. automethod:: ilis
    .. automethod:: lexicons
    .. automethod:: expanded_lexicons
+   .. automethod:: describe
 
 
 The Word Class
@@ -298,6 +299,7 @@ The Lexicon Class
    .. automethod:: requires
    .. automethod:: extends
    .. automethod:: extensions
+   .. automethod:: describe
 
 
 The wn.config Object
diff --git a/docs/api/wn.similarity.rst b/docs/api/wn.similarity.rst
index 9547a7b..5fce418 100644
--- a/docs/api/wn.similarity.rst
+++ b/docs/api/wn.similarity.rst
@@ -11,12 +11,16 @@ Similarity_>`_, and `Wu-Palmer <Wu-Palmer Similarity_>`_ similarity
 metrics work by finding path distances in the hypernym/hyponym
 taxonomy. As such, they are most useful when the synsets are, in fact,
 arranged in a taxonomy. For the Princeton WordNet and derivative
-wordnets, synsets for nouns and verbs are arranged taxonomically: the
-nouns mostly form a single structure with a single root while verbs
-form many smaller structures with many roots. Synsets for the other
-parts of speech do not use hypernym/hyponym relations at all. This
-situation may be different for other wordnet projects or future
-versions of the English wordnets.
+wordnets, such as the `Open English Wordnet`_ and `OMW English Wordnet
+based on WordNet 3.0`_ available to Wn, synsets for nouns and verbs
+are arranged taxonomically: the nouns mostly form a single structure
+with a single root while verbs form many smaller structures with many
+roots. Synsets for the other parts of speech do not use
+hypernym/hyponym relations at all. This situation may be different for
+other wordnet projects or future versions of the English wordnets.
+
+.. _Open English Wordnet: https://en-word.net
+.. _OMW English Wordnet based on WordNet 3.0: https://github.com/omwn/omw-data
 
 The similarity metrics tend to fail when the synsets are not connected
 by some path. When the synsets are in different parts of speech, or
diff --git a/docs/api/wn.taxonomy.rst b/docs/api/wn.taxonomy.rst
index 41ac25f..3c551be 100644
--- a/docs/api/wn.taxonomy.rst
+++ b/docs/api/wn.taxonomy.rst
@@ -20,12 +20,12 @@ and ``instance_hyponym``, which encode *is-an-instance-of*
 relationships (e.g., *Oregon* is an instance of *American state*).
 
 The taxonomy forms a multiply-inheriting hierarchy with the synsets as
-nodes. In the English wordnets, such as the Princeton WordNet, nearly
-all nominal synsets form such a hierarchy with single root node, while
-verbal synsets form many smaller hierarchies without a common
-root. Other wordnets may have different properties, but as many are
-based off of the Princeton WordNet, they tend to follow this
-structure.
+nodes. In the English wordnets, such as the Princeton WordNet and its
+derivatives, nearly all nominal synsets form such a hierarchy with
+single root node, while verbal synsets form many smaller hierarchies
+without a common root. Other wordnets may have different properties,
+but as many are based off of the Princeton WordNet, they tend to
+follow this structure.
 
 Functions to find paths within the taxonomies form the basis of all
 :mod:`wordnet similarity measures <wn.similarity>`. For instance, the
diff --git a/docs/api/wn.validate.rst b/docs/api/wn.validate.rst
new file mode 100644
index 0000000..921c980
--- /dev/null
+++ b/docs/api/wn.validate.rst
@@ -0,0 +1,7 @@
+
+wn.validate
+===========
+
+.. automodule:: wn.validate
+
+.. autofunction:: validate
diff --git a/docs/cli.rst b/docs/cli.rst
index 2429413..4a9b2e3 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -11,29 +11,35 @@ Global Options
    Change to use ``DIR`` as the data directory prior to invoking any
    commands.
 
+
 Subcommands
 -----------
 
 download
 --------
 
-You can give one or more project specifiers to the ``download``
-subcommand:
+Download and add projects to the database given one or more project
+specifiers or URLs.
 
 .. code-block:: console
 
-   $ python -m wn download pwn:3.0 omw
+   $ python -m wn download oewn:2021 omw:1.4 cili
+   $ python -m wn download https://en-word.net/static/english-wordnet-2021.xml.gz
 
 .. option:: --index FILE
 
-   With the ``--index`` option, you can point to a TOML file that will
-   be loaded with :meth:`wn.config.load_index
-   <wn._config.WNConfig.load_index>` prior to downloading:
+   Use the index at ``FILE`` to resolve project specifiers.
 
    .. code-block:: console
 
       $ python -m wn download --index my-index.toml mywn
 
+.. option:: --no-add
+
+   Download and cache the remote file, but don't add it to the
+   database.
+
+
 lexicons
 --------
 
@@ -42,15 +48,14 @@ The ``lexicons`` subcommand lets you quickly see what is installed:
 .. code-block:: console
 
    $ python -m wn lexicons
-   litwn:1.3+omw    [lt]   Lithuanian  WordNet
-   nnown:1.3+omw    [nn]   Norwegian Wordnet
-   jpnwn:1.3+omw    [jp]   Japanese Wordnet
-   nobwn:1.3+omw    [nb]   Norwegian Wordnet
-   arbwn:1.3+omw    [arb]  Arabic WordNet (AWN v2)
-   porwn:1.3+omw    [pt]   OpenWN-PT
-   cmnwn:1.3+omw    [zh]   Chinese Open Wordnet
-   pwn:3.0          [en]   Princeton WordNet 3.0
-   pwn:3.1          [en]   Princeton WordNet 3.1
+   omw-en	1.4	[en]	OMW English Wordnet based on WordNet 3.0
+   omw-sk	1.4	[sk]	Slovak WordNet
+   omw-pl	1.4	[pl]	plWordNet
+   omw-is	1.4	[is]	IceWordNet
+   omw-zsm	1.4	[zsm]	Wordnet Bahasa (Malaysian)
+   omw-sl	1.4	[sl]	sloWNet
+   omw-ja	1.4	[ja]	Japanese Wordnet
+   ...
 
 .. option:: -l LG, --lang LG
 .. option:: --lexicon SPEC
@@ -61,10 +66,56 @@ The ``lexicons`` subcommand lets you quickly see what is installed:
    .. code-block:: console
 
       $ python -m wn lexicons --lang en
-      pwn:3.0          [en]   Princeton WordNet 3.0
-      pwn:3.1          [en]   Princeton WordNet 3.1
-      $ python -m wn lexicons --lexicon "litwn pwn:* jpnwn:1.3+omw"
-      litwn:1.3+omw    [lt]   Lithuanian  WordNet
-      jpnwn:1.3+omw    [jp]   Japanese Wordnet
-      pwn:3.0          [en]   Princeton WordNet 3.0
-      pwn:3.1          [en]   Princeton WordNet 3.1
+      oewn	2021	[en]	Open English WordNet
+      omw-en	1.4	[en]	OMW English Wordnet based on WordNet 3.0
+      $ python -m wn lexicons --lexicon "omw-*"
+      omw-en	1.4	[en]	OMW English Wordnet based on WordNet 3.0
+      omw-sk	1.4	[sk]	Slovak WordNet
+      omw-pl	1.4	[pl]	plWordNet
+      omw-is	1.4	[is]	IceWordNet
+      omw-zsm	1.4	[zsm]	Wordnet Bahasa (Malaysian)
+
+
+projects
+--------
+
+The ``projects`` subcommand lists all known projects in Wn's
+index. This is helpful to see what is available for downloading.
+
+.. code-block::
+
+   $ python -m wn projects
+   ic	cili	1.0	[---]	Collaborative Interlingual Index
+   ic	oewn	2021	[en]	Open English WordNet
+   ic	ewn	2020	[en]	Open English WordNet
+   ic	ewn	2019	[en]	Open English WordNet
+   i-	odenet	1.4	[de]	Open German WordNet
+   ic	odenet	1.3	[de]	Open German WordNet
+   ic	omw	1.4	[mul]	Open Multilingual Wordnet
+   ic	omw-en	1.4	[en]	OMW English Wordnet based on WordNet 3.0
+   ...
+
+
+validate
+--------
+
+Given a path to a WN-LMF XML file, check the file for structural
+problems and print a report.
+
+.. code-block::
+
+   $ python -m wn validate english-wordnet-2021.xml
+
+.. option:: --select CHECKS
+
+   Run the checks with the given comma-separated list of check codes
+   or categories.
+
+   .. code-block::
+
+      $ python -m wn validate --select E W201 W204 deWordNet.xml
+
+.. option:: --output-file FILE
+
+   Write the report to FILE as a JSON object instead of printing the
+   report to stdout.
diff --git a/docs/faq.rst b/docs/faq.rst
index 2b95943..fa76ddd 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -4,7 +4,7 @@ FAQ
 Is Wn related to the NLTK's `nltk.corpus.wordnet` module?
 ---------------------------------------------------------
 
-Only in spirit. There was an effort to develop the NLTK's module as a
+Only in spirit. There was an effort to develop the `NLTK`_\ 's module as a
 standalone package (see https://github.com/nltk/wordnet/), but
 development had slowed. Wn has the same broad goals and a similar API
 as that standalone package, but fundamental architectural differences
@@ -19,51 +19,89 @@ Is Wn compatible with the NLTK's module?
 The API is intentionally similar, but not exactly the same (for
 instance see the next question), and there are differences in the ways
 that results are retrieved, particularly for non-English wordnets. See
-:doc:`guides/nltk-migration` for more information.
+:doc:`guides/nltk-migration` for more information. Also see
+:ref:`princeton-wordnet`.
 
 Where are the ``Lemma`` objects? What are ``Word`` and ``Sense`` objects?
 -------------------------------------------------------------------------
 
-While senses are essentially links between words (also called "lexical
-entries") and synsets, they may contain metadata and be the source or
-target of sense relations, so in some ways they are more like nodes
-than edges when the wordnet is viewed as a graph. The NLTK chose to
-conflate words and senses into a single object called a ``Lemma``, but
-Wn keeps them separate. Wn also has an unrelated concept called a
-"lemma", but it is merely the canonical form of a word.
+Unlike the original `WNDB`_ data format of the original WordNet, the
+`WN-LMF`_ XML format grants words (called *lexical entries* in WN-LMF
+and a :class:`~wn.Word` object in Wn) and word senses
+(:class:`~wn.Sense` in Wn) explicit, first-class status alongside
+synsets.  While senses are essentially links between words and
+synsets, they may contain metadata and be the source or target of
+sense relations, so in some ways they are more like nodes than edges
+when the wordnet is viewed as a graph. The `NLTK`_\ 's module, using
+the WNDB format, combines the information of a word and a sense into a
+single object called a ``Lemmas``. Wn also has an unrelated concept
+called a :meth:`~wn.Word.lemma`, but it is merely the canonical form
+of a word.
+
+.. _princeton-wordnet:
+
+Where is the Princeton WordNet data?
+------------------------------------
+
+The original English wordnet, named simply *WordNet* but often
+referred to as the *Princeton WordNet* to better distinguish it from
+other projects, is specifically the data distributed by Princeton in
+the `WNDB`_ format. The `Open Multilingual Wordnet <OMW_>`_ (OMW)
+packages an export of the WordNet data as the *OMW English Wordnet
+based on WordNet 3.0* which is used by Wn (with the lexicon ID
+``omw-en``). It also has a similar export for WordNet 3.1 data
+(``omw-en31``). Both of these are highly compatible with the original
+data and can be used as drop-in replacements.
+
+Prior to Wn version 0.9 (and, correspondingly, prior to the `OMW
+data`_ version 1.4), the ``pwn:3.0`` and ``pwn:3.1`` English wordnets
+distributed by OMW were incorrectly called the *Princeton WordNet*
+(for WordNet 3.0 and 3.1, respectively). From Wn version 0.9 (and from
+version 1.4 of the OMW data), these are called the *OMW English
+Wordnet based on WordNet 3.0/3.1* (``omw-en:1.4`` and
+``omw-en31:1.4``, respectively). These lexicons are intentionally
+compatible with the original WordNet data, and the 1.4 versions are
+even more compatible than the previous ``pwn:3.0`` and ``pwn:3.1``
+lexicons, so it is strongly recommended to use them over the previous
+versions.
+
+.. _OMW data: https://github.com/omwn/omw-data
 
 Why don't all wordnets share the same synsets?
 ----------------------------------------------
 
-The `Open Multilingual Wordnet <https://lr.soh.ntu.edu.sg/omw/omw>`_
-(OMW) contains wordnets for many languages created using the *expand*
-methodology [VOSSEN1998]_, where non-English wordnets provide words on
-top of the Princeton WordNet's synset structure. This allows new
-wordnets to be built in much less time than starting from scratch, but
-with a few drawbacks, such as that words cannot be added if they do
-not have a synset in the Princeton WordNet, and that it is difficult
-to version the wordnets independently (e.g., for reproducibility of
-experiments involving wordnet data) as all are interconnected. Wn,
-therefore, creates new synsets for each wordnet added to its database,
-and synsets then specify which resource they belong to. Queries can
-specify which resources may be examined. Also see
-:doc:`guides/interlingual`.
+The `Open Multilingual Wordnet <OMW_>`_ (OMW) contains wordnets for
+many languages created using the *expand* methodology [VOSSEN1998]_,
+where non-English wordnets provide words on top of the English
+wordnet's synset structure. This allows new wordnets to be built in
+much less time than starting from scratch, but with a few drawbacks,
+such as that words cannot be added if they do not have a synset in the
+English wordnet, and that it is difficult to version the wordnets
+independently (e.g., for reproducibility of experiments involving
+wordnet data) as all are interconnected. Wn, therefore, creates new
+synsets for each wordnet added to its database, and synsets then
+specify which resource they belong to. Queries can specify which
+resources may be examined. Also see :doc:`guides/interlingual`.
 
 Why does Wn's database get so big?
 ----------------------------------
 
-The Princeton WordNet 3.0 takes about 104 MiB of disk space in Wn's
-database, which is only about 6 MiB more than it takes as a `WN-LMF
-XML <https://globalwordnet.github.io/schemas/>`_ file. The NLTK,
-however, uses the obsolete WNDB format which is more compact,
-requiring only 35 MiB of disk space. The difference with the Open
-Multilingual Wordnet 1.3 is more striking: it takes about 466 MiB of
-disk space in the database, but only 49 MiB in the NLTK. Part of the
-difference here is that the OMW files in the NLTK are simple
-tab-separated-value files listing only the words added to each synset
-for each language. In addition, Wn creates new synsets for each
-wordnet added (see the previous question). One more reason is that Wn
-creates various indexes in the database for efficient lookup.
-
+The *OMW English Wordnet based on WordNet 3.0* takes about 114 MiB of
+disk space in Wn's database, which is only about 8 MiB more than it
+takes as a `WN-LMF`_ XML file. The `NLTK`_, however, uses the obsolete
+`WNDB`_ format which is more compact, requiring only 35 MiB of disk
+space. The difference with the Open Multilingual Wordnet 1.4 is more
+striking: it takes about 659 MiB of disk space in the database, but
+only 49 MiB in the NLTK. Part of the difference here is that the OMW
+files in the NLTK are simple tab-separated-value files listing only
+the words added to each synset for each language. In addition, Wn
+creates new synsets for each wordnet added (see the previous
+question). One more reason is that Wn creates various indexes in the
+database for efficient lookup.
 
+.. _NLTK: https://www.nltk.org/
+.. _OMW: http://github.com/omwn
 .. [VOSSEN1998] Piek Vossen. 1998. *Introduction to EuroWordNet.* Computers and the Humanities, 32(2): 73--89.
+.. _Open English Wordnet 2021: https://en-word.net/
+.. _WNDB: https://wordnet.princeton.edu/documentation/wndb5wn
+.. _WN-LMF: https://globalwordnet.github.io/schemas/
diff --git a/docs/guides/lemmatization.rst b/docs/guides/lemmatization.rst
index c111b7d..8b90b9e 100644
--- a/docs/guides/lemmatization.rst
+++ b/docs/guides/lemmatization.rst
@@ -23,11 +23,11 @@ database generally contains only lemmatic forms, or *lemmas* (or
 *lemmata*, if you prefer irregular plurals).
 
 >>> import wn
->>> ewn = wn.Wordnet('ewn:2020')
->>> ewn.words('plurals')  # no results
+>>> en = wn.Wordnet('oewn:2021')
+>>> en.words('plurals')
 []
->>> ewn.words('plural')
-[Word('ewn-plural-n'), Word('ewn-plural-a')]
+>>> en.words('plural')
+[Word('oewn-plural-a'), Word('oewn-plural-n')]
 
 Lemmas are sometimes called *citation forms* or *dictionary forms* as
 they are often used as the head words in dictionary entries. In
@@ -92,12 +92,11 @@ to a :class:`wn.Wordnet` object. For example, using :mod:`wn.morphy`:
 
 >>> import wn
 >>> from wn.morphy import Morphy
->>> ewn = wn.Wordnet('ewn:2020', lemmatizer=Morphy())
->>> ewn.words('sparrows')
-[Word('ewn-sparrow-n')]
->>> ewn.words('leaves')
-[Word('ewn-leaf-n'), Word('ewn-leave-n'), Word('ewn-leave-v')]
-
+>>> en = wn.Wordnet('oewn:2021', lemmatizer=Morphy())
+>>> en.words('sparrows')
+[Word('oewn-sparrow-n')]
+>>> en.words('leaves')
+[Word('oewn-leave-v'), Word('oewn-leaf-n'), Word('oewn-leave-n')]
 
 Querying Without Lemmatization
 ''''''''''''''''''''''''''''''
@@ -105,25 +104,23 @@ Querying Without Lemmatization
 When lemmatization is not used, inflected terms may not return any
 results:
 
->>> ewn = wn.Wordnet('ewn:2020')
->>> ewn.words('sparrows')
-[]
->>> ewn.words('leaves')
+>>> en = wn.Wordnet('oewn:2021')
+>>> en.words('sparrows')
 []
 
 Depending on the lexicon, there may be situations where results are
 returned for inflected lemmas, such as when the inflected form is
 lexicalized as its own entry:
 
->>> ewn.words('glasses')
-[Word('ewn-glasses-n')]
+>>> en.words('glasses')
+[Word('oewn-glasses-n')]
 
 Or if the lexicon lists the inflected form as an alternative form. For
 example, the English Wordnet lists irregular inflections as
 alternative forms:
 
->>> ewn.words('lemmata')
-[Word('ewn-lemma-n')]
+>>> en.words('lemmata')
+[Word('oewn-lemma-n')]
 
 See below for excluding alternative forms from such queries.
 
@@ -141,22 +138,21 @@ orthographies (original, hiragana, katakana, and two romanizations).
 For the English Wordnet, this means that you might get basic
 lemmatization for irregular forms only:
 
->>> ewn = wn.Wordnet('ewn:2020')
->>> ewn.words('learnt', pos='v')
-[Word('ewn-learn-v')]
->>> ewn.words('learned', pos='v')
+>>> en = wn.Wordnet('oewn:2021')
+>>> en.words('learnt', pos='v')
+[Word('oewn-learn-v')]
+>>> en.words('learned', pos='v')
 []
 
 If this is undesirable, the alternative forms can be excluded from
 queries with the *search_all_forms* parameter:
 
->>> ewn = wn.Wordnet('ewn:2020', search_all_forms=False)
->>> ewn.words('learnt', pos='v')
+>>> en = wn.Wordnet('oewn:2021', search_all_forms=False)
+>>> en.words('learnt', pos='v')
 []
->>> ewn.words('learned', pos='v')
+>>> en.words('learned', pos='v')
 []
 
-
 .. _normalization:
 
 Normalization
@@ -171,13 +167,13 @@ presented to users. For instance, a user might attempt to look up
 form without diacritics: *resume*. With strict string matching, the
 entry would not be found using the wordform in the query. By
 normalizing the query word, the entry can be found. Similarly in the
-Spanish wordnet, *año* (year) and *ano* (anus) are two different
-words. A user who types *año* likely does not want to get results for
-*ano*, but one who types *ano* may be a non-Spanish speaker who is
-unaware of the missing diacritic or does not have an input method that
-allows them to type the diacritic, so this query would return both
-entries by matching against the normalized forms in the database. Wn
-handles all of these use cases.
+Spanish wordnet, *soñar* (to dream) and *sonar* (to ring) are two
+different words. A user who types *soñar* likely does not want to get
+results for *sonar*, but one who types *sonar* may be a non-Spanish
+speaker who is unaware of the missing diacritic or does not have an
+input method that allows them to type the diacritic, so this query
+would return both entries by matching against the normalized forms in
+the database. Wn handles all of these use cases.
 
 When a lexicon is added to the database, potentially two wordforms are
 inserted for every one in the lexicon: the original wordform and a
@@ -212,7 +208,7 @@ as the original, only the original is inserted into the database.
    Original Form  Normalized Form
    =============  ===============
    résumé         resume
-   año            ano
+   soñar          sonar
    San José       san jose
    ハラペーニョ   ハラヘーニョ
    =============  ===============
@@ -228,14 +224,14 @@ check the original wordform in the query against the normalized forms
 in the database and, if no results are returned in the first step, it
 allows the queried wordform to be normalized as a back-off technique.
 
->>> ewn = wn.Wordnet('ewn:2020')
->>> ewn.words('résumé')
-[Word('ewn-resume-v'), Word('ewn-resume-n')]
->>> spa = wn.Wordnet('spawn:1.3+omw')
->>> spa.words('año')
-[Word('spawn-lex57514')]
->>> spa.words('ano')
-[Word('spawn-lex34109'), Word('spawn-lex57514')]
+>>> en = wn.Wordnet('oewn:2021')
+>>> en.words('résumé')
+[Word('oewn-resume-n'), Word('oewn-resume-v')]
+>>> es = wn.Wordnet('omw-es:1.4')
+>>> es.words('soñar')
+[Word('omw-es-soñar-v')]
+>>> es.words('sonar')
+[Word('omw-es-sonar-v'), Word('omw-es-soñar-v')]
 
 .. note::
 
@@ -253,14 +249,14 @@ argument of the *normalizer* parameter of :class:`wn.Wordnet`. The
 queried wordform will not be checked against normalized forms in the
 database and neither will it be normalized as a back-off technique.
 
->>> ewn = wn.Wordnet('ewn:2020', normalizer=None)
->>> ewn.words('résumé')
+>>> en = wn.Wordnet('oewn:2021', normalizer=None)
+>>> en.words('résumé')
 []
->>> spa = wn.Wordnet('spawn:1.3+omw', normalizer=None)
->>> spa.words('año')
-[Word('spawn-lex57514')]
->>> spa.words('ano')
-[Word('spawn-lex34109')]
+>>> es = wn.Wordnet('omw-es:1.4', normalizer=None)
+>>> es.words('soñar')
+[Word('omw-es-soñar-v')]
+>>> es.words('sonar')
+[Word('omw-es-sonar-v')]
 
 .. note::
 
diff --git a/docs/guides/lexicons.rst b/docs/guides/lexicons.rst
index 46d78cc..e2e4cf7 100644
--- a/docs/guides/lexicons.rst
+++ b/docs/guides/lexicons.rst
@@ -21,13 +21,14 @@ In Wn, the following terminology is used:
 In general, each resource contains one lexicon. For large projects
 like the `Open English WordNet`_, that lexicon is also a wordnet on
 its own. For a collection like the `Open Multilingual Wordnet`_, most
-lexicons do not include relations as they instead use those from the
-`Princeton WordNet`_. As such, a wordnet for these sub-projects is
-best thought of as the grouping of the lexicon with the Princeton
-WordNet's lexicon.
+lexicons do not include relations as they are instead expected to use
+those from the OMW's included English wordnet, which is derived from
+the `Princeton WordNet`_. As such, a wordnet for these sub-projects is
+best thought of as the grouping of the lexicon with the lexicon
+providing the relations.
 
 .. _Open English WordNet: https://en-word.net
-.. _Open Multilingual Wordnet: https://lr.soh.ntu.edu.sg/omw/omw
+.. _Open Multilingual Wordnet: https://github.com/omwn/
 .. _Princeton WordNet: https://wordnet.princeton.edu/
 
 .. _lexicon-specifiers:
@@ -48,62 +49,75 @@ version, delimited by ``:``. Here are the possible forms:
     id:version  -- the lexicon with the given id and version
     *:version   -- all lexicons with the given version
 
-For example, if both the ``2020`` and ``2019`` versions of the `Open
-English Wordnet`_ were installed, in that
-order, then ``ewn`` would specify the ``2019`` version, ``ewn:*``
-would specify both versions, and ``ewn:2020`` would specify the
-``2020`` version.
+For example, if ``ewn:2020`` was installed followed by ``ewn:2019``,
+then ``ewn`` would specify the ``2019`` version, ``ewn:*`` would
+specify both versions, and ``ewn:2020`` would specify the ``2020``
+version.
 
 The same format is used for *project specifiers*, which refer to
-projects. In most cases the project specifier is the same as the
-lexicon specifier (e.g., ``ewn:2020`` refers both to the project to be
-downloaded and the lexicon that is installed), but sometimes it is
-not. The 1.3 release of the `Open Multilingual Wordnet`_, for
-instance, has the project specifier ``omw:1.3`` but it installs a
-number of lexicons with their own lexicon specifiers
-(``zsmwn:1.3+omw``, ``cmnwn:1.3+omw``, etc.). When only an id is given
+projects as defined in Wn's index. In most cases the project specifier
+is the same as the lexicon specifier (e.g., ``ewn:2020`` refers both
+to the project to be downloaded and the lexicon that is installed),
+but sometimes it is not. The 1.4 release of the `Open Multilingual
+Wordnet`_, for instance, has the project specifier ``omw:1.4`` but it
+installs a number of lexicons with their own lexicon specifiers
+(``omw-zsm:1.4``, ``omw-cmn:1.4``, etc.). When only an id is given
 (e.g., ``ewn``), a project specifier gets the *first* version listed
-in the index (in the default index, the first version is the latest
-release).
+in the index (in the default index, conventionally, the first version
+is the latest release).
 
 Downloading Lexicons
 --------------------
 
 Use :py:func:`wn.download` to download lexicons from the web given
-either an indexed project identifier (and optionally a version) or the
-URL of a resource, package, or collection.
+either an indexed project specifier or the URL of a resource, package,
+or collection.
 
 >>> import wn
->>> wn.download('ewn')  # get the latest Open English WordNet
->>> wn.download('ewn:2019')  # get the 2019 version
+>>> wn.download('odenet')  # get the latest Open German WordNet
+>>> wn.download('odenet:1.3')  # get the 1.3 version
 >>> # download from a URL
->>> wn.download('https://github.com/bond-lab/omw-data/releases/download/v1.3/omw-1.3.tar.xz')
+>>> wn.download('https://github.com/omwn/omw-data/releases/download/v1.4/omw-1.4.tar.xz')
 
-The resource, package, or collection may contain multiple
-lexicons. The IDs in the lexicons are what's saved to disk, not the
-project identifier.
+The project specifier is only used to retrieve information from Wn's
+index. The lexicon IDs of the corresponding resource files are what is
+stored in the database.
 
 Adding Local Lexicons
 ---------------------
 
 Lexicons can be added from local files with :py:func:`wn.add`:
 
->>> wn.add('~/data/omw/nob/nobwn.xml')
+>>> wn.add('~/data/omw-1.4/omw-nb/omw-nb.xml')
 
-Or, with the directory as a package:
+Or with the parent directory as a package:
 
->>> wn.add('~/data/omw/nob/')
+>>> wn.add('~/data/omw-1.4/omw-nb/')
+
+Or with the grandparent directory as a collection (installing all
+packages contained by the collection):
+
+>>> wn.add('~/data/omw-1.4/')
+
+Or from a compressed archive of one of the above:
+
+>>> wn.add('~/data/omw-1.4/omw-nb/omw-nb.xml.xz')
+>>> wn.add('~/data/omw-1.4/omw-nb.tar.xz')
+>>> wn.add('~/data/omw-1.4.tar.xz')
 
 Listing Installed Lexicons
 --------------------------
 
-If you wish to see which lexicons have been added to the database, :py:func:`wn.lexicons()` returns the list of :py:class:`wn.Lexicon` objects that describe each one.
+If you wish to see which lexicons have been added to the database,
+:py:func:`wn.lexicons()` returns the list of :py:class:`wn.Lexicon`
+objects that describe each one.
 
 >>> for lex in wn.lexicons():
 ...     print(f'{lex.id}:{lex.version}\t{lex.label}')
 ...
-nobwn:1.3+omw	Norwegian Wordnet
-pwn:3.0	Princeton WordNet 3.0
+omw-en:1.4	OMW English Wordnet based on WordNet 3.0
+omw-nb:1.4	Norwegian Wordnet (Bokmål)
+odenet:1.3	Offenes Deutsches WordNet
 ewn:2020	English WordNet
 ewn:2019	English WordNet
 
@@ -112,13 +126,13 @@ Removing Lexicons
 
 Lexicons can be removed from the database with :py:func:`wn.remove`:
 
->>> wn.remove('nobwn:1.3+omw')
+>>> wn.remove('omw-nb:1.4')
 
 Note that this removes a single lexicon and not a project, so if, for
 instance, you've installed a multi-lexicon project like ``omw``, you
 will need to remove each lexicon individually or use a star specifier:
 
->>> wn.remove('*:1.3+omw')
+>>> wn.remove('omw-*:1.4')
 
 WN-LMF Files, Packages, and Collections
 ---------------------------------------
@@ -152,23 +166,32 @@ files and Wn will recognize three of them:
 
 .. code-block::
 
-   alswn/
-   ├── alswn.xml
+   omw-sq/
+   ├── omw-sq.xml
    ├── LICENSE.txt
    └── README.md
 
+WN-LMF Collections
+''''''''''''''''''
+
+In some cases a project may manage multiple resources and distribute
+them as a collection. A collection is a directory containing
+subdirectories which are WN-LMF packages. The collection may contain
+its own README, LICENSE, and citation files which describe the project
+as a whole.
 
 .. code-block::
 
-   collection/
-   ├── alswn
-   │   ├── alswn.xml
+   omw-1.4/
+   ├── omw-sq
+   │   ├── oms-sq.xml
    │   ├── LICENSE.txt
    │   └── README.md
-   ├── litwn
+   ├── omw-lt
    │   ├── citation.bib
    │   ├── LICENSE
-   │   └── litwn.xml
+   │   └── omw-lt.xml
+   ├── ...
    ├── citation.bib
    ├── LICENSE
    └── README.md
diff --git a/docs/guides/nltk-migration.rst b/docs/guides/nltk-migration.rst
index 9cc13d1..fcd6d2c 100644
--- a/docs/guides/nltk-migration.rst
+++ b/docs/guides/nltk-migration.rst
@@ -22,8 +22,8 @@ database by default where the NLTK would only search the English.
 With Wn it helps to create a :class:`wn.Wordnet` object to pre-filter
 the results by language or lexicon.
 
->>> pwn = wn.Wordnet('pwn', '3.0')
->>> pwn.synsets('chat')                     # only Princeton WordNet 3.0
+>>> pwn = wn.Wordnet('omw-en:1.4')
+>>> pwn.synsets('chat')                     # only the OMW English Wordnet
 
 Equivalent Operations
 ---------------------
@@ -40,7 +40,7 @@ NLTK:
 Wn:
 
 >>> import wn
->>> pwn = wn.Wordnet('pwn', '3.0')
+>>> pwn = wn.Wordnet('omw-en:1.4')
 >>> ss = pwn.synsets("chat", pos="v")[0]
 
 .. default-role:: python
diff --git a/docs/index.rst b/docs/index.rst
index bc52e04..bb49f26 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -65,4 +65,5 @@ Contents
    api/wn.similarity.rst
    api/wn.taxonomy.rst
    api/wn.util.rst
+   api/wn.validate.rst
    api/wn.web.rst
diff --git a/wn/__init__.py b/wn/__init__.py
index c07178c..b5e5808 100644
--- a/wn/__init__.py
+++ b/wn/__init__.py
@@ -31,10 +31,18 @@
     'ILI',
     'Error',
     'DatabaseError',
+    'ConfigurationError',
+    'ProjectError',
     'WnWarning',
 )
 
-from wn._exceptions import Error, DatabaseError, WnWarning
+from wn._exceptions import (
+    Error,
+    DatabaseError,
+    ConfigurationError,
+    ProjectError,
+    WnWarning,
+)
 from wn._config import config  # noqa: F401
 from wn._add import add, remove
 from wn._export import export
@@ -49,4 +57,4 @@
     Wordnet
 )
 
-__version__ = '0.8.3'
+__version__ = '0.9.0'
diff --git a/wn/__main__.py b/wn/__main__.py
index 42c2c6f..4bd3c23 100644
--- a/wn/__main__.py
+++ b/wn/__main__.py
@@ -1,8 +1,14 @@
+
+import sys
 import argparse
 from pathlib import Path
+import json
 import logging
 
 import wn
+from wn.project import iterpackages
+from wn import lmf
+from wn.validate import validate
 
 
 def _download(args):
@@ -33,6 +39,38 @@ def _projects(args):
         )
 
 
+def _validate(args):
+    all_valid = True
+    selectseq = [check.strip() for check in args.select.split(',')]
+    for package in iterpackages(args.FILE):
+        resource = lmf.load(package.resource_file())
+        for lexicon in resource['lexicons']:
+            spec = f'{lexicon["id"]}:{lexicon["version"]}'
+            print(f'{spec:<20}', end='')
+            report = validate(lexicon, select=selectseq)
+            if not any(check.get('items', []) for check in report.values()):
+                print('passed')
+            else:
+                print('failed')
+                all_valid = False
+                # clean up report
+                for code in list(report):
+                    if not report[code].get('items'):
+                        del report[code]
+                if args.output_file:
+                    with open(args.output_file, 'w') as outfile:
+                        json.dump(report, outfile, indent=2)
+                else:
+                    for code, check in report.items():
+                        if not check['items']:
+                            continue
+                        print(f'  {check["message"]}')
+                        for id, context in check['items'].items():
+                            print(f'    {id}: {context}' if context else f'    {id}')
+
+    sys.exit(0 if all_valid else 1)
+
+
 def _path_type(arg):
     return Path(arg)
 
@@ -107,6 +145,27 @@ def _file_path_type(arg):
 parser_projects.set_defaults(func=_projects)
 
 
+parser_validate = sub_parsers.add_parser(
+    'validate',
+    description=(
+        "Validate a WN-LMF lexicon"
+    ),
+    help='validate a lexicon',
+)
+parser_validate.add_argument(
+    'FILE', type=_file_path_type, help='WN-LMF (XML) lexicon file to validate'
+)
+parser_validate.add_argument(
+    '--select', metavar='CHECKS', default='E,W',
+    help='comma-separated list of checks to run (default: E,W)'
+)
+parser_validate.add_argument(
+    '--output-file', metavar='FILE',
+    help='write report to a JSON file'
+)
+parser_validate.set_defaults(func=_validate)
+
+
 args = parser.parse_args()
 
 logging.basicConfig(level=logging.ERROR - (min(args.verbosity, 3) * 10))
diff --git a/wn/_add.py b/wn/_add.py
index 94cd6ff..396815a 100644
--- a/wn/_add.py
+++ b/wn/_add.py
@@ -2,8 +2,13 @@
 Adding and removing lexicons to/from the database.
 """
 
-from typing import Optional, Type, Tuple, List
+from typing import (
+    Union, Optional, TypeVar, Type, Tuple, List, Dict, Set,
+    Iterator, Iterable, Sequence, cast
+)
+from pathlib import Path
 from itertools import islice
+import sqlite3
 import logging
 
 import wn
@@ -69,6 +74,13 @@
      WHERE lf.name = ?
 '''
 
+_AnyLexicon = Union[lmf.Lexicon, lmf.LexiconExtension]
+_AnyEntry = Union[lmf.LexicalEntry, lmf.ExternalLexicalEntry]
+_AnyLemma = Union[lmf.Lemma, lmf.ExternalLemma]
+_AnyForm = Union[lmf.Form, lmf.ExternalForm]
+_AnySense = Union[lmf.Sense, lmf.ExternalSense]
+_AnySynset = Union[lmf.Synset, lmf.ExternalSynset]
+
 
 def add(
     source: AnyPath,
@@ -107,7 +119,7 @@ def add(
 
 
 def _add_lmf(
-    source,
+    source: Path,
     progress: ProgressHandler,
     progress_handler: Type[ProgressHandler],
 ) -> None:
@@ -121,7 +133,7 @@ def _add_lmf(
 
         # abort if any lexicon in *source* is already added
         progress.flash(f'Checking {source!s}')
-        all_infos = list(_precheck(source, cur))
+        all_infos = _precheck(source, cur)
 
         if not all_infos:
             progress.flash(f'{source}: No lexicons found')
@@ -136,22 +148,23 @@ def _add_lmf(
 
         # all clear, try to add them
         progress.flash(f'Reading {source!s}')
+        resource = lmf.load(source, progress_handler)
 
-        for lexicon, info in zip(lmf.load(source, progress_handler), all_infos):
+        for lexicon, info in zip(resource['lexicons'], all_infos):
             if 'skip' in info:
                 continue
 
             progress.flash('Updating lookup tables')
             _update_lookup_tables(lexicon, cur)
 
-            progress.set(count=0, total=_sum_counts(info))
-            synsets = lexicon.synsets
-            entries = lexicon.lexical_entries
-            synbhrs = lexicon.syntactic_behaviours
+            progress.set(count=0, total=_sum_counts(lexicon))
+            synsets: Sequence[_AnySynset] = _synsets(lexicon)
+            entries: Sequence[_AnyEntry] = _entries(lexicon)
+            synbhrs: Sequence[lmf.SyntacticBehaviour] = _collect_frames(lexicon)
 
-            lexid, qryid = _insert_lexicon(lexicon, info, cur, progress)
+            lexid, extid = _insert_lexicon(lexicon, info, cur, progress)
 
-            lexidmap = _build_lexid_map(lexicon, lexid, qryid)
+            lexidmap = _build_lexid_map(lexicon, lexid, extid)
 
             _insert_synsets(synsets, lexid, cur, progress)
             _insert_entries(entries, lexid, cur, progress)
@@ -167,61 +180,100 @@ def _add_lmf(
             _insert_sense_relations(lexicon, lexid, lexidmap, cur, progress)
 
             _insert_synset_definitions(synsets, lexid, lexidmap, cur, progress)
-            _insert_examples([sense for entry in entries for sense in entry.senses],
+            _insert_examples([sense for e in entries for sense in _senses(e)],
                              lexid, lexidmap, 'sense_examples', cur, progress)
             _insert_examples(synsets, lexid, lexidmap, 'synset_examples', cur, progress)
 
             progress.set(status='')  # clear type string
-            progress.flash(f'Added {lexicon.id}:{lexicon.version} ({lexicon.label})\n')
+            progress.flash(
+                f"Added {lexicon['id']}:{lexicon['version']} ({lexicon['label']})\n"
+            )
 
 
-def _precheck(source, cur):
-    lexqry = 'SELECT * FROM lexicons WHERE id = ? AND version = ?'
-    for info in lmf.scan_lexicons(source):
-        id = info['id']
-        version = info['version']
+def _precheck(source: Path, cur: sqlite3.Cursor) -> List[Dict]:
+    lexqry = 'SELECT * FROM lexicons WHERE id = :id AND version = :version'
+    infos = lmf.scan_lexicons(source)
+    for info in infos:
         base = info.get('extends')
-        if cur.execute(lexqry, (id, version)).fetchone():
+        if cur.execute(lexqry, info).fetchone():
             info['skip'] = 'already added'
         if base and cur.execute(lexqry, base).fetchone() is None:
-            info['skip'] = f'base lexicon ({base[0]}:{base[1]}) not available'
-        yield info
-
-
-def _sum_counts(info) -> int:
-    counts = info['counts']
-    return sum(counts.get(name, 0) for name in lmf.LEXICON_INFO_ATTRIBUTES)
-
-
-def _update_lookup_tables(lexicon, cur):
-    reltypes = set(rel.type
-                   for ss in lexicon.synsets
-                   for rel in ss.relations)
-    reltypes.update(rel.type
-                    for e in lexicon.lexical_entries
-                    for s in e.senses
-                    for rel in s.relations)
+            id_, ver = base['id'], base['version']
+            info['skip'] = f'base lexicon ({id_}:{ver}) not available'
+    return infos
+
+
+def _sum_counts(lex: _AnyLexicon) -> int:
+    ents = _entries(lex)
+    locs = _local_entries(ents)
+    lems = [e['lemma'] for e in locs if e.get('lemma')]
+    frms = [f for e in ents for f in _forms(e)]
+    sens = [s for e in ents for s in _senses(e)]
+    syns = _synsets(lex)
+    return sum([
+        # lexical entries
+        len(ents),
+        len(lems),
+        sum(len(lem.get('pronunciations', [])) for lem in lems),
+        sum(len(lem.get('tags', [])) for lem in lems),
+        len(frms),
+        sum(len(frm.get('pronunciations', [])) for frm in frms),
+        sum(len(frm.get('tags', [])) for frm in frms),
+        # senses
+        len(sens),
+        sum(len(sen.get('relations', [])) for sen in sens),
+        sum(len(sen.get('examples', [])) for sen in sens),
+        sum(len(sen.get('counts', [])) for sen in sens),
+        # synsets
+        len(syns),
+        sum(len(syn.get('definitions', [])) for syn in syns),
+        sum(len(syn.get('relations', [])) for syn in syns),
+        sum(len(syn.get('examples', [])) for syn in syns),
+        # syntactic behaviours
+        sum(len(ent.get('frames', [])) for ent in locs),
+        len(lex.get('frames', [])),
+    ])
+
+
+def _update_lookup_tables(
+    lexicon: _AnyLexicon,
+    cur: sqlite3.Cursor
+) -> None:
+    reltypes = set(rel['relType']
+                   for ss in _synsets(lexicon)
+                   for rel in ss.get('relations', []))
+    reltypes.update(rel['relType']
+                    for e in _entries(lexicon)
+                    for s in _senses(e)
+                    for rel in s.get('relations', []))
     cur.executemany('INSERT OR IGNORE INTO relation_types VALUES (null,?)',
                     [(rt,) for rt in sorted(reltypes)])
-    lexfiles = set(ss.lexfile for ss in lexicon.synsets) - {None}
+    lexfiles: Set[str] = {ss.get('lexfile', '')
+                          for ss in _local_synsets(_synsets(lexicon))
+                          if ss.get('lexfile')}
     cur.executemany('INSERT OR IGNORE INTO lexfiles VALUES (null,?)',
                     [(lf,) for lf in sorted(lexfiles)])
 
 
-def _insert_lexicon(lexicon, info, cur, progress) -> Tuple[int, int]:
+def _insert_lexicon(
+    lexicon: _AnyLexicon,
+    info: dict,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> Tuple[int, int]:
     progress.set(status='Lexicon Info')
     cur.execute(
         'INSERT INTO lexicons VALUES (null,?,?,?,?,?,?,?,?,?,?,?)',
-        (lexicon.id,
-         lexicon.label,
-         lexicon.language,
-         lexicon.email,
-         lexicon.license,
-         lexicon.version,
-         lexicon.url,
-         lexicon.citation,
-         lexicon.logo,
-         lexicon.meta,
+        (lexicon['id'],
+         lexicon['label'],
+         lexicon['language'],
+         lexicon['email'],
+         lexicon['license'],
+         lexicon['version'],
+         lexicon.get('url'),
+         lexicon.get('citation'),
+         lexicon.get('logo'),
+         lexicon.get('meta'),
          False))
     lexid = cur.lastrowid
 
@@ -230,7 +282,7 @@ def _insert_lexicon(lexicon, info, cur, progress) -> Tuple[int, int]:
            SET provider_rowid = ?
          WHERE provider_id = ? AND provider_version = ?
     '''
-    cur.execute(query, (lexid, lexicon.id, lexicon.version))
+    cur.execute(query, (lexid, lexicon['id'], lexicon['version']))
 
     query = '''
         INSERT INTO {table}
@@ -241,7 +293,7 @@ def _insert_lexicon(lexicon, info, cur, progress) -> Tuple[int, int]:
                 (SELECT rowid FROM lexicons WHERE id=:id AND version=:version))
     '''
     params = []
-    for dep in lexicon.requires:
+    for dep in lexicon.get('requires', []):
         param_dict = dict(dep)
         param_dict.setdefault('url', None)
         param_dict['lid'] = lexid
@@ -249,34 +301,46 @@ def _insert_lexicon(lexicon, info, cur, progress) -> Tuple[int, int]:
     if params:
         cur.executemany(query.format(table='lexicon_dependencies'), params)
 
-    if lexicon.extends:
-        param_dict = dict(lexicon.extends)
+    if lexicon.get('extends'):
+        lexicon = cast(lmf.LexiconExtension, lexicon)
+        param_dict = dict(lexicon['extends'])
         param_dict.setdefault('url', None)
         param_dict['lid'] = lexid
         cur.execute(query.format(table='lexicon_extensions'), param_dict)
-        qryid = cur.execute(
+        extid = cur.execute(
             'SELECT rowid FROM lexicons WHERE id=? AND version=?',
             (param_dict['id'], param_dict['version'])
         ).fetchone()[0]
     else:
-        qryid = lexid
+        extid = lexid
+
+    return lexid, extid
+
 
-    return lexid, qryid
+_LexIdMap = Dict[str, int]
 
 
-def _build_lexid_map(lexicon, lexid, qryid):
-    lexidmap = {}
-    if lexid != qryid:
-        lexidmap.update((e.id, qryid) for e in lexicon.lexical_entries if e.external)
-        lexidmap.update((s.id, qryid)
-                        for e in lexicon.lexical_entries
-                        for s in e.senses
-                        if s.external)
-        lexidmap.update((ss.id, qryid) for ss in lexicon.synsets if ss.external)
+def _build_lexid_map(lexicon: _AnyLexicon, lexid: int, extid: int) -> _LexIdMap:
+    """Build a mapping of entity IDs to extended lexicon rowid."""
+    lexidmap: _LexIdMap = {}
+    if lexid != extid:
+        lexidmap.update((e['id'], extid)
+                        for e in _entries(lexicon)
+                        if _is_external(e))
+        lexidmap.update((s['id'], extid)
+                        for e in _entries(lexicon)
+                        for s in _senses(e)
+                        if _is_external(s))
+        lexidmap.update((ss['id'], extid)
+                        for ss in _synsets(lexicon)
+                        if _is_external(ss))
     return lexidmap
 
 
-def _split(sequence):
+T = TypeVar('T')
+
+
+def _batch(sequence: Iterable[T]) -> Iterator[List[T]]:
     it = iter(sequence)
     batch = list(islice(it, 0, BATCH_SIZE))
     while len(batch):
@@ -284,7 +348,12 @@ def _split(sequence):
         batch = list(islice(it, 0, BATCH_SIZE))
 
 
-def _insert_synsets(synsets, lexid, cur, progress):
+def _insert_synsets(
+    synsets: Sequence[_AnySynset],
+    lexid: int,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Synsets')
     # synsets
     ss_query = f'''
@@ -307,180 +376,236 @@ def _insert_synsets(synsets, lexid, cur, progress):
                ?)
     '''
 
-    for batch in _split(synsets):
+    for batch in _batch(_local_synsets(synsets)):
 
         # first add presupposed ILIs
-        data = []
+        pre_ili_data = []
         for ss in batch:
-            ili = ss.ili
+            ili = ss['ili']
             if ili and ili != 'in':
-                defn = ss.ili_definition
-                text = defn.text if defn else None
-                meta = defn.meta if defn else None
-                data.append((ili, 'presupposed', text, meta))
-        cur.executemany(pre_ili_query, data)
+                defn = ss.get('ili_definition')  # normally null
+                text = defn['text'] if defn else None
+                meta = defn['meta'] if defn else None
+                pre_ili_data.append((ili, 'presupposed', text, meta))
+        cur.executemany(pre_ili_query, pre_ili_data)
 
         # then add synsets
-        data = (
-            (ss.id,
+        ss_data = (
+            (ss['id'],
              lexid,
-             ss.ili if ss.ili and ss.ili != 'in' else None,
-             ss.pos,
-             ss.lexicalized,
-             ss.lexfile,
-             ss.meta)
-            for ss in batch if not ss.external
+             ss['ili'] if ss['ili'] and ss['ili'] != 'in' else None,
+             ss['partOfSpeech'],
+             ss.get('lexicalized', True),
+             ss.get('lexfile'),
+             ss['meta'])
+            for ss in batch
         )
-        cur.executemany(ss_query, data)
+        cur.executemany(ss_query, ss_data)
 
         # finally add proposed ILIs
-        data = []
+        pro_ili_data = []
         for ss in batch:
-            ili = ss.ili
+            ili = ss['ili']
             if ili == 'in':
-                defn = ss.ili_definition
-                text = defn.text if defn else None
-                meta = defn.meta if defn else None
-                data.append((ss.id, lexid, text, meta))
-        cur.executemany(pro_ili_query, data)
+                defn = ss.get('ili_definition')
+                text = defn['text'] if defn else None
+                meta = defn['meta'] if defn else None
+                pro_ili_data.append((ss['id'], lexid, text, meta))
+        cur.executemany(pro_ili_query, pro_ili_data)
 
         progress.update(len(batch))
 
 
-def _insert_synset_definitions(synsets, lexid, lexidmap, cur, progress):
+def _insert_synset_definitions(
+    synsets: Sequence[_AnySynset],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Definitions')
     query = f'''
         INSERT INTO definitions
         VALUES (null,?,({SYNSET_QUERY}),?,?,({SENSE_QUERY}),?)
     '''
-    for batch in _split(synsets):
+    for batch in _batch(synsets):
         data = [
             (lexid,
-             synset.id, lexidmap.get(synset.id, lexid),
-             definition.text,
-             definition.language,
-             definition.source_sense, lexidmap.get(definition.source_sense, lexid),
-             definition.meta)
+             synset['id'],
+             lexidmap.get(synset['id'], lexid),
+             definition['text'],
+             definition.get('language'),
+             definition.get('sourceSense'),
+             lexidmap.get(definition.get('sourceSense', ''), lexid),
+             definition['meta'])
             for synset in batch
-            for definition in synset.definitions
+            for definition in synset.get('definitions', [])
         ]
         cur.executemany(query, data)
         progress.update(len(data))
 
 
-def _insert_synset_relations(synsets, lexid, lexidmap, cur, progress):
+def _insert_synset_relations(
+    synsets: Sequence[_AnySynset],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Synset Relations')
     query = f'''
         INSERT INTO synset_relations
         VALUES (null,?,({SYNSET_QUERY}),({SYNSET_QUERY}),({RELTYPE_QUERY}),?)
     '''
-    for batch in _split(synsets):
+    for batch in _batch(synsets):
         data = [
             (lexid,
-             synset.id, lexidmap.get(synset.id, lexid),
-             relation.target, lexidmap.get(relation.target, lexid),
-             relation.type,
-             relation.meta)
+             synset['id'], lexidmap.get(synset['id'], lexid),
+             relation['target'], lexidmap.get(relation['target'], lexid),
+             relation['relType'],
+             relation['meta'])
             for synset in batch
-            for relation in synset.relations
+            for relation in synset.get('relations', [])
         ]
         cur.executemany(query, data)
         progress.update(len(data))
 
 
-def _insert_entries(entries, lexid, cur, progress):
+def _insert_entries(
+    entries: Sequence[_AnyEntry],
+    lexid: int,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Words')
     query = 'INSERT INTO entries VALUES (null,?,?,?,?)'
-    for batch in _split(entries):
+    for batch in _batch(_local_entries(entries)):
         data = (
-            (entry.id,
+            (entry['id'],
              lexid,
-             entry.lemma.pos,
-             entry.meta)
-            for entry in batch if not entry.external
+             entry['lemma']['partOfSpeech'],
+             entry['meta'])
+            for entry in batch
         )
         cur.executemany(query, data)
         progress.update(len(batch))
 
 
-def _insert_forms(entries, lexid, lexidmap, cur, progress):
+def _insert_forms(
+    entries: Sequence[_AnyEntry],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Word Forms')
     query = f'INSERT INTO forms VALUES (null,?,?,({ENTRY_QUERY}),?,?,?,?)'
-    for batch in _split(entries):
-        forms = []
+    for batch in _batch(entries):
+        forms: List[Tuple[Optional[str], int, str, int,
+                          str, Optional[str], Optional[str], int]] = []
         for entry in batch:
-            eid = entry.id
-            lemma = entry.lemma
+            eid = entry['id']
             lid = lexidmap.get(eid, lexid)
-            if not entry.external:
-                form = lemma.form
-                norm = normalize_form(form)
+            if not _is_external(entry):
+                entry = cast(lmf.LexicalEntry, entry)
+                written_form = entry['lemma']['writtenForm']
+                norm = normalize_form(written_form)
                 forms.append(
                     (None, lexid, eid, lid,
-                     form, norm if norm != form else None,
-                     lemma.script, 0)
+                     written_form, norm if norm != written_form else None,
+                     entry['lemma'].get('script'), 0)
                 )
-            for i, form in enumerate(entry.forms, 1):
-                _form = form.form
-                norm = normalize_form(_form)
+            for i, form in enumerate(_forms(entry), 1):
+                if _is_external(form):
+                    continue
+                form = cast(lmf.Form, form)
+                written_form = form['writtenForm']
+                norm = normalize_form(written_form)
                 forms.append(
-                    (form.id, lexid, eid, lid,
-                     form.form, norm if norm != form else None,
-                     form.script, i)
+                    (form.get('id'), lexid, eid, lid,
+                     written_form, norm if norm != written_form else None,
+                     form.get('script'), i)
                 )
         cur.executemany(query, forms)
         progress.update(len(forms))
 
 
-def _insert_pronunciations(entries, lexid, lexidmap, cur, progress):
+def _insert_pronunciations(
+    entries: Sequence[_AnyEntry],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Pronunciations')
     query = f'INSERT INTO pronunciations VALUES (({FORM_QUERY}),?,?,?,?,?)'
-    for batch in _split(entries):
-        prons = []
+    for batch in _batch(entries):
+        prons: List[Tuple[str, int, Optional[str], int,
+                          str, Optional[str], Optional[str],
+                          bool, Optional[str]]] = []
         for entry in batch:
-            eid = entry.id
+            eid = entry['id']
             lid = lexidmap.get(eid, lexid)
-            if entry.lemma:
-                for p in entry.lemma.pronunciations:
+            if entry.get('lemma'):
+                for p in entry['lemma'].get('pronunciations', []):
                     prons.append(
                         (eid, lid, None, 0,
-                         p.value, p.variety, p.notation, p.phonemic, p.audio)
+                         p['text'], p.get('variety'), p.get('notation'),
+                         p.get('phonemic', True), p.get('audio'))
                     )
-            for i, form in enumerate(entry.forms, 1):
+            for i, form in enumerate(_forms(entry), 1):
                 # rank is not valid in FORM_QUERY for external forms
-                rank = -1 if form.external else i
-                for p in form.pronunciations:
+                rank = -1 if _is_external(form) else i
+                for p in form.get('pronunciations', []):
                     prons.append(
-                        (eid, lid, form.id, rank,
-                         p.text, p.variety, p.notation, p.phonemic, p.audio)
+                        (eid, lid, form.get('id'), rank,
+                         p['text'], p.get('variety'), p.get('notation'),
+                         p.get('phonemic', True), p.get('audio'))
                     )
         cur.executemany(query, prons)
         progress.update(len(prons))
 
 
-def _insert_tags(entries, lexid, lexidmap, cur, progress):
+def _insert_tags(
+    entries: Sequence[_AnyEntry],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Word Form Tags')
     query = f'INSERT INTO tags VALUES (({FORM_QUERY}),?,?)'
-    for batch in _split(entries):
-        tags = []
+    for batch in _batch(entries):
+        tags: List[Tuple[str, int, Optional[str], int, str, str]] = []
         for entry in batch:
-            eid = entry.id
+            eid = entry['id']
             lid = lexidmap.get(eid, lexid)
-            if entry.lemma:
-                for tag in entry.lemma.tags:
-                    tags.append((eid, lid, None, 0, tag.text, tag.category))
-            for i, form in enumerate(entry.forms, 1):
+            if entry.get('lemma'):
+                for tag in entry['lemma'].get('tags', []):
+                    tags.append((eid, lid, None, 0, tag['text'], tag['category']))
+            for i, form in enumerate(_forms(entry), 1):
                 # rank is not valid in FORM_QUERY for external forms
-                rank = -1 if form.external else i
-                for tag in form.tags:
-                    tags.append((eid, lid, form.id, rank, tag.text, tag.category))
+                rank = -1 if _is_external(form) else i
+                for tag in form.get('tags', []):
+                    tags.append(
+                        (eid, lid, form.get('id'), rank, tag['text'], tag['category'])
+                    )
         cur.executemany(query, tags)
         progress.update(len(tags))
 
 
-def _insert_senses(entries, synsets, lexid, lexidmap, cur, progress):
+def _insert_senses(
+    entries: Sequence[_AnyEntry],
+    synsets: Sequence[_AnySynset],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Senses')
-    ssrank = {s: i for ss in synsets for i, s in enumerate(ss.members)}
+    ssrank = {s: i
+              for ss in _local_synsets(synsets)
+              for i, s in enumerate(ss.get('members', []))}
     query = f'''
         INSERT INTO senses
         VALUES (null,
@@ -493,58 +618,113 @@ def _insert_senses(entries, synsets, lexid, lexidmap, cur, progress):
                 ?,
                 ?)
     '''
-    for batch in _split(entries):
+    for batch in _batch(entries):
         data = [
-            (sense.id,
+            (sense['id'],
              lexid,
-             entry.id, lexidmap.get(entry.id, lexid),
+             entry['id'], lexidmap.get(entry['id'], lexid),
              i,
-             sense.synset, lexidmap.get(sense.synset, lexid),
-             ssrank.get(sense.id, DEFAULT_MEMBER_RANK),
-             sense.lexicalized,
-             sense.meta)
+             sense['synset'], lexidmap.get(sense['synset'], lexid),
+             ssrank.get(sense['id'], DEFAULT_MEMBER_RANK),
+             sense.get('lexicalized', True),
+             sense['meta'])
             for entry in batch
-            for i, sense in enumerate(entry.senses)
-            if not sense.external
+            for i, sense in enumerate(_local_senses(_senses(entry)))
         ]
         cur.executemany(query, data)
         progress.update(len(data))
 
 
-def _insert_adjpositions(entries, lexid, lexidmap, cur, progress):
+def _insert_adjpositions(
+    entries: Sequence[_AnyEntry],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+):
     progress.set(status='Sense Adjpositions')
-    data = [(s.id, lexidmap.get(s.id, lexid), s.adjposition)
+    data = [(s['id'], lexidmap.get(s['id'], lexid), s['adjposition'])
             for e in entries
-            for s in e.senses
-            if s.adjposition and not s.external]
+            for s in _local_senses(_senses(e))
+            if s.get('adjposition')]
     query = f'INSERT INTO adjpositions VALUES (({SENSE_QUERY}),?)'
     cur.executemany(query, data)
 
 
-def _insert_counts(entries, lexid, lexidmap, cur, progress):
+def _insert_counts(
+    entries: Sequence[_AnyEntry],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Counts')
     data = [(lexid,
-             sense.id, lexidmap.get(sense.id, lexid),
-             count.value,
-             count.meta)
+             sense['id'], lexidmap.get(sense['id'], lexid),
+             count['value'],
+             count['meta'])
             for entry in entries
-            for sense in entry.senses
-            for count in sense.counts]
+            for sense in _senses(entry)
+            for count in sense.get('counts', [])]
     query = f'INSERT INTO counts VALUES (null,?,({SENSE_QUERY}),?,?)'
     cur.executemany(query, data)
     progress.update(len(data))
 
 
-def _insert_syntactic_behaviours(synbhrs, lexid, lexidmap, cur, progress):
+def _collect_frames(lexicon: _AnyLexicon) -> List[lmf.SyntacticBehaviour]:
+    # WN-LMF 1.0 syntactic behaviours are on lexical entries, and in
+    # WN-LMF 1.1 they are at the lexticon level with IDs. This
+    # function normalizes the two variants.
+
+    # IDs are not required and frame strings must be unique in a
+    # lexicon, so lookup syntactic behaviours by the frame string
+    synbhrs: Dict[str, lmf.SyntacticBehaviour] = {
+        frame['subcategorizationFrame']: {
+            'id': frame['id'],
+            'subcategorizationFrame': frame['subcategorizationFrame'],
+        }
+        for frame in lexicon.get('frames', [])
+    }
+    # all relevant senses are collected into the 'senses' key
+    id_senses_map = {sb['id']: sb.get('senses', [])
+                     for sb in synbhrs.values() if sb.get('id')}
+    for entry in _entries(lexicon):
+        # for WN-LMF 1.1
+        for sense in _local_senses(_senses(entry)):
+            for sbid in sense.get('subcat', []):
+                id_senses_map[sbid].append(sense['id'])
+        # for WN-LMF 1.0
+        if _is_external(entry) or not entry.get('frames'):
+            continue
+        entry = cast(lmf.LexicalEntry, entry)
+        all_senses = [s['id'] for s in _senses(entry)]
+        for frame in entry.get('frames', []):
+            subcat_frame = frame['subcategorizationFrame']
+            if subcat_frame not in synbhrs:
+                synbhrs[subcat_frame] = {'subcategorizationFrame': subcat_frame}
+            senses = frame.get('senses', []) or all_senses
+            synbhrs[subcat_frame].setdefault('senses', []).extend(senses)
+    return list(synbhrs.values())
+
+
+def _insert_syntactic_behaviours(
+    synbhrs: Sequence[lmf.SyntacticBehaviour],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Syntactic Behaviours')
-    # syntactic behaviours don't have a required ID; index on frame
-    framemap = {}
-    for sb in synbhrs:
-        framemap.setdefault(sb.frame, []).extend(sb.senses)
 
     query = 'INSERT INTO syntactic_behaviours VALUES (null,?,?,?)'
-    cur.executemany(query, [(None, lexid, frame) for frame in framemap])
+    sbdata = [(sb.get('id') or None, lexid, sb['subcategorizationFrame'])
+              for sb in synbhrs]
+    cur.executemany(query, sbdata)
 
+    # syntactic behaviours don't have a required ID; index on frame
+    framemap: Dict[str, List[str]] = {
+        sb['subcategorizationFrame']: sb.get('senses', []) for sb in synbhrs
+    }
     query = f'''
         INSERT INTO syntactic_behaviour_senses
         VALUES ((SELECT rowid
@@ -552,31 +732,39 @@ def _insert_syntactic_behaviours(synbhrs, lexid, lexidmap, cur, progress):
                   WHERE lexicon_rowid=? AND frame=?),
                 ({SENSE_QUERY}))
     '''
-    data = [(lexid, frame, sid, lexidmap.get(sid, lexid))
-            for frame in framemap
-            for sid in framemap[frame]]
-    cur.executemany(query, data)
+    sbsdata = [(lexid, frame, sid, lexidmap.get(sid, lexid))
+               for frame in framemap
+               for sid in framemap[frame]]
+    cur.executemany(query, sbsdata)
 
     progress.update(len(synbhrs))
 
 
-def _insert_sense_relations(lexicon, lexid, lexidmap, cur, progress):
+def _insert_sense_relations(
+    lexicon: _AnyLexicon,
+    lexid: int,
+    lexidmap: _LexIdMap,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Sense Relations')
     # need to separate relations into those targeting senses vs synsets
-    synset_ids = {ss.id for ss in lexicon.synsets}
-    sense_ids = {s.id for e in lexicon.lexical_entries for s in e.senses}
+    synset_ids = {ss['id'] for ss in _synsets(lexicon)}
+    sense_ids = {s['id']
+                 for e in _entries(lexicon)
+                 for s in _senses(e)}
     s_s_rels = []
     s_ss_rels = []
-    for entry in lexicon.lexical_entries:
-        for sense in entry.senses:
-            slid = lexidmap.get(sense.id, lexid)
-            for relation in sense.relations:
-                target_id = relation.target
+    for entry in _entries(lexicon):
+        for sense in _senses(entry):
+            slid = lexidmap.get(sense['id'], lexid)
+            for relation in sense.get('relations', []):
+                target_id = relation['target']
                 tlid = lexidmap.get(target_id, lexid)
                 if target_id in sense_ids:
-                    s_s_rels.append((sense.id, slid, tlid, relation))
+                    s_s_rels.append((sense['id'], slid, tlid, relation))
                 elif target_id in synset_ids:
-                    s_ss_rels.append((sense.id, slid, tlid, relation))
+                    s_ss_rels.append((sense['id'], slid, tlid, relation))
                 else:
                     raise wn.Error(
                         f'relation target is not a known sense or synset: {target_id}'
@@ -590,34 +778,41 @@ def _insert_sense_relations(lexicon, lexid, lexidmap, cur, progress):
             INSERT INTO {table}
             VALUES (null,?,({SENSE_QUERY}),({target_query}),({RELTYPE_QUERY}),?)
         '''
-        for batch in _split(rels):
+        for batch in _batch(rels):
             data = [
                 (lexid,
                  sense_id, slid,
-                 relation.target, tlid,
-                 relation.type,
-                 relation.meta)
+                 relation['target'], tlid,
+                 relation['relType'],
+                 relation['meta'])
                 for sense_id, slid, tlid, relation in batch
             ]
             cur.executemany(query, data)
             progress.update(len(data))
 
 
-def _insert_examples(objs, lexid, lexidmap, table, cur, progress):
+def _insert_examples(
+    objs: Sequence[Union[lmf.Sense, lmf.ExternalSense, lmf.Synset, lmf.ExternalSynset]],
+    lexid: int,
+    lexidmap: _LexIdMap,
+    table: str,
+    cur: sqlite3.Cursor,
+    progress: ProgressHandler
+) -> None:
     progress.set(status='Examples')
     if table == 'sense_examples':
         query = f'INSERT INTO {table} VALUES (null,?,({SENSE_QUERY}),?,?,?)'
     else:
         query = f'INSERT INTO {table} VALUES (null,?,({SYNSET_QUERY}),?,?,?)'
-    for batch in _split(objs):
+    for batch in _batch(objs):
         data = [
             (lexid,
-             obj.id, lexidmap.get(obj.id, lexid),
-             example.text,
-             example.language,
-             example.meta)
+             obj['id'], lexidmap.get(obj['id'], lexid),
+             example['text'],
+             example.get('language'),
+             example['meta'])
             for obj in batch
-            for example in obj.examples
+            for example in obj.get('examples', [])
         ]
         # be careful of SQL injection here
         cur.executemany(query, data)
@@ -625,7 +820,7 @@ def _insert_examples(objs, lexid, lexidmap, table, cur, progress):
 
 
 def _add_ili(
-    source,
+    source: Path,
     progress: ProgressHandler,
 ) -> None:
     query = f'''
@@ -647,7 +842,7 @@ def _add_ili(
                         [(stat,) for stat in sorted(statuses)])
 
         progress.set(count=0, total=len(ili), status='ILI')
-        for batch in _split(ili):
+        for batch in _batch(ili):
             data = [
                 (info['ili'],
                  info.get('status', 'active'),
@@ -712,3 +907,36 @@ def _find_all_extensions(rowid: int) -> List[Tuple[int, str]]:
         lexinfo = get_lexicon(ext_id)
         exts.append((ext_id, f'{lexinfo[1]}:{lexinfo[6]}'))
     return exts
+
+
+def _entries(lex: _AnyLexicon) -> Sequence[_AnyEntry]: return lex.get('entries', [])
+def _forms(e: _AnyEntry) -> Sequence[_AnyForm]: return e.get('forms', [])
+def _senses(e: _AnyEntry) -> Sequence[_AnySense]: return e.get('senses', [])
+def _synsets(lex: _AnyLexicon) -> Sequence[_AnySynset]: return lex.get('synsets', [])
+
+
+def _is_external(
+    x: Union[_AnyForm, _AnyLemma, _AnyEntry, _AnySense, _AnySynset]
+) -> bool:
+    return x.get('external', False) is True
+
+
+def _local_synsets(synsets: Sequence[_AnySynset]) -> Iterator[lmf.Synset]:
+    for ss in synsets:
+        if _is_external(ss):
+            continue
+        yield cast(lmf.Synset, ss)
+
+
+def _local_entries(entries: Sequence[_AnyEntry]) -> Iterator[lmf.LexicalEntry]:
+    for e in entries:
+        if _is_external(e):
+            continue
+        yield cast(lmf.LexicalEntry, e)
+
+
+def _local_senses(senses: Sequence[_AnySense]) -> Iterator[lmf.Sense]:
+    for s in senses:
+        if _is_external(s):
+            continue
+        yield cast(lmf.Sense, s)
diff --git a/wn/_config.py b/wn/_config.py
index 0f919f2..a30e828 100644
--- a/wn/_config.py
+++ b/wn/_config.py
@@ -3,15 +3,15 @@
 Local configuration settings.
 """
 
-from typing import Dict
+from typing import Optional, Dict, Sequence, Any
 from pathlib import Path
 
 import tomli
 
-from wn import Error
+from wn import ConfigurationError, ProjectError
 from wn._types import AnyPath
 from wn.constants import _WORDNET
-from wn._util import is_url, resources, short_hash
+from wn._util import resources, short_hash
 
 # The directory where downloaded and added data will be stored.
 DEFAULT_DATA_DIRECTORY = Path.home() / '.wn_data'
@@ -37,7 +37,7 @@ def data_directory(self) -> Path:
     def data_directory(self, path):
         dir = Path(path).expanduser()
         if dir.exists() and not dir.is_dir():
-            raise Error(f'path exists and is not a directory: {dir}')
+            raise ConfigurationError(f'path exists and is not a directory: {dir}')
         self._data_directory = dir
         self._dbpath = dir / DATABASE_FILENAME
 
@@ -65,14 +65,18 @@ def add_project(
         label: str = None,
         language: str = None,
         license: str = None,
+        error: str = None,
     ) -> None:
         """Add a new wordnet project to the index.
 
         Arguments:
             id: short identifier of the project
+            type: project type (default 'wordnet')
             label: full name of the project
             language: `BCP 47`_ language code of the resource
             license: link or name of the project's default license
+            error: if set, the error message to use when the project
+              is accessed
 
         .. _BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
         """
@@ -83,34 +87,49 @@ def add_project(
             'label': label,
             'language': language,
             'versions': {},
-            'license': license
+            'license': license,
         }
+        if error:
+            self._projects[id]['error'] = error
 
     def add_project_version(
-            self,
-            id: str,
-            version: str,
-            url: str,
-            license: str = None,
+        self,
+        id: str,
+        version: str,
+        url: str = None,
+        error: str = None,
+        license: str = None,
     ) -> None:
         """Add a new resource version for a project.
 
+        Exactly one of *url* or *error* must be specified.
+
         Arguments:
             id: short identifier of the project
             version: version string of the resource
-            url: web address of the resource
+            url: space-separated list of web addresses for the resource
             license: link or name of the resource's license; if not
               given, the project's default license will be used.
+            error: if set, the error message to use when the project
+              is accessed
 
         """
-        version_data = {'resource_url': url}
+        version_data: Dict[str, Any]
+        if url and not error:
+            version_data = {'resource_urls': url.split()}
+        elif error and not url:
+            version_data = {'error': error}
+        elif url and error:
+            raise ConfigurationError(f'{id}:{version} specifies both url and redirect')
+        else:
+            version_data = {}
         if license:
             version_data['license'] = license
         project = self._projects[id]
         project['versions'][version] = version_data
 
     def get_project_info(self, arg: str) -> Dict:
-        """Return a dictionary of information about an indexed project.
+        """Return information about an indexed project version.
 
         If the project has been downloaded and cached, the ``"cache"``
         key will point to the path of the cached file, otherwise its
@@ -121,21 +140,30 @@ def get_project_info(self, arg: str) -> Dict:
 
         Example:
 
-            >>> info = wn.config.get_project_info('pwn:3.0')
+            >>> info = wn.config.get_project_info('oewn:2021')
             >>> info['label']
-            'Princeton WordNet'
+            'Open English WordNet'
 
         """
         id, _, version = arg.partition(':')
+        if id not in self._projects:
+            raise ProjectError(f'no such project id: {id}')
         project: Dict = self._projects[id]
+        if 'error' in project:
+            raise ProjectError(project['error'])
+
         versions: Dict = project['versions']
         if not version or version == '*':
-            version = next(iter(versions))
-        if version not in versions:
-            raise Error(f'no such version: {version!r} ({project})')
+            version = next(iter(versions), '')
+        if not version:
+            raise ProjectError(f'no versions available for {id}')
+        elif version not in versions:
+            raise ProjectError(f'no such version: {version!r} ({id})')
+        info = versions[version]
+        if 'error' in info:
+            raise ProjectError(info['error'])
 
-        url = versions[version]['resource_url']
-        cache_path = self.get_cache_path(url)
+        urls = info.get('resource_urls', [])
 
         return dict(
             id=id,
@@ -143,23 +171,19 @@ def get_project_info(self, arg: str) -> Dict:
             type=project['type'],
             label=project['label'],
             language=project['language'],
-            license=versions[version].get('license', project.get('license')),
-            resource_url=url,
-            cache=cache_path if cache_path.exists() else None
+            license=info.get('license', project.get('license')),
+            resource_urls=urls,
+            cache=_get_cache_path_for_urls(self, urls),
         )
 
-    def get_cache_path(self, arg: str) -> Path:
-        """Return the path for caching *arg*.
+    def get_cache_path(self, url: str) -> Path:
+        """Return the path for caching *url*.
 
-        The *arg* argument may be either a URL or a project specifier
-        that gets passed to :meth:`get_project_info`. Note that this
-        is just a path operation and does not signify that the file
-        exists in the file system.
+        Note that in general this is just a path operation and does
+        not signify that the file exists in the file system.
 
         """
-        if not is_url(arg):
-            arg = self.get_project_info(arg)['resource_url']
-        filename = short_hash(arg)
+        filename = short_hash(url)
         return self.downloads_directory / filename
 
     def update(self, data: dict) -> None:
@@ -180,7 +204,7 @@ def update(self, data: dict) -> None:
                 _project = self._projects[id]
                 for attr in ('label', 'language', 'license'):
                     if attr in project and project[attr] != _project[attr]:
-                        raise Error(f'{attr} mismatch for {id}')
+                        raise ConfigurationError(f'{attr} mismatch for {id}')
             else:
                 self.add_project(
                     id,
@@ -188,13 +212,19 @@ def update(self, data: dict) -> None:
                     label=project.get('label'),
                     language=project.get('language'),
                     license=project.get('license'),
+                    error=project.get('error'),
                 )
             for version, info in project.get('versions', {}).items():
+                if 'url' in info and 'error' in project:
+                    raise ConfigurationError(
+                        f'{id}:{version} url specified with default error'
+                    )
                 self.add_project_version(
                     id,
                     version,
-                    info['url'],
+                    url=info.get('url'),
                     license=info.get('license'),
+                    error=info.get('error'),
                 )
 
     def load_index(self, path: AnyPath) -> None:
@@ -219,10 +249,24 @@ def load_index(self, path: AnyPath) -> None:
         """
         path = Path(path).expanduser()
         with path.open('rb') as indexfile:
-            index = tomli.load(indexfile)
+            try:
+                index = tomli.load(indexfile)
+            except tomli.TOMLDecodeError as exc:
+                raise ConfigurationError('malformed index file') from exc
         self.update({'index': index})
 
 
+def _get_cache_path_for_urls(
+    config: WNConfig,
+    urls: Sequence[str],
+) -> Optional[Path]:
+    for url in urls:
+        path = config.get_cache_path(url)
+        if path.is_file():
+            return path
+    return None
+
+
 config = WNConfig()
 with resources.path('wn', 'index.toml') as index_path:
     config.load_index(index_path)
diff --git a/wn/_core.py b/wn/_core.py
index 1aa4186..03fea1f 100644
--- a/wn/_core.py
+++ b/wn/_core.py
@@ -12,6 +12,7 @@
     Iterator,
 )
 import warnings
+import textwrap
 
 import wn
 from wn._types import (
@@ -205,6 +206,44 @@ def extensions(self, depth: int = 1) -> List['Lexicon']:
         return [_to_lexicon(get_lexicon(rowid))
                 for rowid in get_lexicon_extensions(self._id, depth=depth)]
 
+    def describe(self, full: bool = True) -> str:
+        """Return a formatted string describing the lexicon.
+
+        The *full* argument (default: :python:`True`) may be set to
+        :python:`False` to omit word and sense counts.
+
+        Also see: :meth:`Wordnet.describe`
+
+        """
+        _id = self._id
+        substrings: List[str] = [
+            f'{self.specifier()}',
+            f'  Label  : {self.label}',
+            f'  URL    : {self.url}',
+            f'  License: {self.license}',
+        ]
+        if full:
+            substrings.extend([
+                f'  Words  : {_desc_counts(find_entries, _id)}',
+                f'  Senses : {sum(1 for _ in find_senses(lexicon_rowids=(_id,)))}',
+            ])
+        substrings.extend([
+            f'  Synsets: {_desc_counts(find_synsets, _id)}',
+            f'  ILIs   : {sum(1 for ili, *_ in find_ilis(lexicon_rowids=(_id,))):>6}',
+        ])
+        return '\n'.join(substrings)
+
+
+def _desc_counts(query: Callable, lexid: int) -> str:
+    count: Dict[str, int] = {}
+    for _, pos, *_ in query(lexicon_rowids=(lexid,)):
+        if pos not in count:
+            count[pos] = 1
+        else:
+            count[pos] += 1
+    subcounts = ', '.join(f'{pos}: {count[pos]}' for pos in sorted(count))
+    return f'{sum(count.values()):>6} ({subcounts})'
+
 
 class _LexiconElement(_DatabaseEntity):
     __slots__ = '_lexid', '_wordnet'
@@ -298,10 +337,7 @@ def __eq__(self, other):
         return str.__eq__(self, other)
 
     def __hash__(self):
-        script = self.script
-        if script is None:
-            return str.__hash__(self)
-        return hash((str(self), self.script))
+        return str.__hash__(self)
 
     def pronunciations(self) -> List[Pronunciation]:
         return [Pronunciation(*data) for data in get_form_pronunciations(self._id)]
@@ -1090,13 +1126,13 @@ def __init__(
         self.lemmatizer = lemmatizer
         self._search_all_forms = search_all_forms
 
-    def lexicons(self):
+    def lexicons(self) -> List[Lexicon]:
         """Return the list of lexicons covered by this wordnet."""
-        return self._lexicons
+        return list(self._lexicons)
 
-    def expanded_lexicons(self):
+    def expanded_lexicons(self) -> List[Lexicon]:
         """Return the list of expand lexicons for this wordnet."""
-        return self._expanded
+        return list(self._expanded)
 
     def word(self, id: str) -> Word:
         """Return the first word in this wordnet with identifier *id*."""
@@ -1177,6 +1213,34 @@ def ilis(self, status: str = None) -> List[ILI]:
         iterable = find_ilis(status=status, lexicon_rowids=self._lexicon_ids)
         return [ILI(*ili_data) for ili_data in iterable]
 
+    def describe(self) -> str:
+        """Return a formatted string describing the lexicons in this wordnet.
+
+        Example:
+
+            >>> oewn = wn.Wordnet('oewn:2021')
+            >>> print(oewn.describe())
+            Primary lexicons:
+              oewn:2021
+                Label  : Open English WordNet
+                URL    : https://github.com/globalwordnet/english-wordnet
+                License: https://creativecommons.org/licenses/by/4.0/
+                Words  : 163161 (a: 8386, n: 123456, r: 4481, s: 15231, v: 11607)
+                Senses : 211865
+                Synsets: 120039 (a: 7494, n: 84349, r: 3623, s: 10727, v: 13846)
+                ILIs   : 120039
+
+        """
+        substrings = ['Primary lexicons:']
+        for lex in self.lexicons():
+            substrings.append(textwrap.indent(lex.describe(), '  '))
+        expanded = self.expanded_lexicons()
+        if expanded:
+            substrings.append('Expand lexicons:')
+            for lex in self.expanded_lexicons():
+                substrings.append(textwrap.indent(lex.describe(full=False), '  '))
+        return '\n'.join(substrings)
+
 
 def _to_lexicon(data) -> Lexicon:
     rowid, id, label, language, email, license, version, url, citation, logo = data
@@ -1235,6 +1299,8 @@ def _find_helper(
     if not forms:
         forms = {pos: {form}}
 
+    # we want unique results here, but a set can make the order
+    # erratic, so filter manually later
     results = [
         cls(*data, w)  # type: ignore
         for _pos, _forms in forms.items()
@@ -1248,7 +1314,13 @@ def _find_helper(
                 forms=[normalize(f) for f in _forms], pos=_pos, **kwargs
             )
         ]
-    return results
+    unique_results: List[C] = []
+    seen: Set[C] = set()
+    for result in results:
+        if result not in seen:
+            unique_results.append(result)
+            seen.add(result)
+    return unique_results
 
 
 def projects() -> List[Dict]:
@@ -1272,7 +1344,8 @@ def projects() -> List[Dict]:
     return [
         wn.config.get_project_info(f'{project_id}:{version}')
         for project_id, project_info in index.items()
-        for version in project_info['versions']
+        for version in project_info.get('versions', [])
+        if 'resource_urls' in project_info['versions'][version]
     ]
 
 
diff --git a/wn/_db.py b/wn/_db.py
index 739b1db..cdc4a21 100644
--- a/wn/_db.py
+++ b/wn/_db.py
@@ -9,9 +9,8 @@
 import logging
 
 import wn
-from wn._types import Metadata, AnyPath
+from wn._types import AnyPath
 from wn._util import resources, short_hash
-from wn import lmf
 
 
 logger = logging.getLogger('wn')
@@ -42,12 +41,11 @@
 
 # Optional metadata is stored as a JSON string
 
-def _adapt_metadata(meta: lmf.Metadata) -> bytes:
-    d = {key: val for key, val in zip(meta._fields, meta) if val is not None}
+def _adapt_dict(d: dict) -> bytes:
     return json.dumps(d).encode('utf-8')
 
 
-def _convert_metadata(s: bytes) -> Metadata:  # note: wn._types.Metadata
+def _convert_dict(s: bytes) -> dict:
     return json.loads(s)
 
 
@@ -55,8 +53,8 @@ def _convert_boolean(s: bytes) -> bool:
     return bool(int(s))
 
 
-sqlite3.register_adapter(lmf.Metadata, _adapt_metadata)
-sqlite3.register_converter('meta', _convert_metadata)
+sqlite3.register_adapter(dict, _adapt_dict)
+sqlite3.register_converter('meta', _convert_dict)
 sqlite3.register_converter('boolean', _convert_boolean)
 
 
diff --git a/wn/_download.py b/wn/_download.py
index d0416c8..babb7a3 100644
--- a/wn/_download.py
+++ b/wn/_download.py
@@ -1,5 +1,5 @@
 
-from typing import Optional, Type
+from typing import Optional, Type, List, Sequence, Tuple
 from pathlib import Path
 import logging
 
@@ -51,25 +51,20 @@ def download(
     created, used, and closed by this function.
 
     """
-    if is_url(project_or_url):
-        url = project_or_url
-    else:
-        info = config.get_project_info(project_or_url)
-        url = info['resource_url']
-    logger.info('download url: %s', url)
-
-    path = config.get_cache_path(url)
-    logger.info('download cache path: %s', path)
-
     if progress_handler is None:
         progress_handler = ProgressHandler
     progress = progress_handler(message='Download', unit=' bytes')
 
+    cache_path, urls = _get_cache_path_and_urls(project_or_url)
+
     try:
-        if path.exists():
-            progress.flash(f'Cached file found: {path!s}')
+        if cache_path and cache_path.exists():
+            progress.flash(f'Cached file found: {cache_path!s}')
+            path = cache_path
+        elif urls:
+            path = _download(urls, progress)
         else:
-            _download(url, path, progress)
+            raise wn.Error('no urls to download')
     finally:
         progress.close()
 
@@ -85,25 +80,48 @@ def download(
     return path
 
 
-def _download(url: str, path: Path, progress: ProgressHandler) -> None:
-    size: int = 0
+def _get_cache_path_and_urls(project_or_url: str) -> Tuple[Optional[Path], List[str]]:
+    if is_url(project_or_url):
+        return config.get_cache_path(project_or_url), [project_or_url]
+    else:
+        info = config.get_project_info(project_or_url)
+        return info.get('cache'), info['resource_urls']
+
+
+def _download(urls: Sequence[str], progress: ProgressHandler) -> Path:
     try:
-        with open(path, 'wb') as f:
-            progress.set(status='Requesting')
-            with requests.get(url, stream=True, timeout=TIMEOUT) as response:
-                size = int(response.headers.get('Content-Length', 0))
-                progress.set(total=size, status='Receiving')
-                for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
-                    if chunk:
-                        f.write(chunk)
-                    progress.update(len(chunk))
-                progress.set(status='Complete')
-    except requests.exceptions.RequestException as exc:
-        path.unlink()
-        raise wn.Error(f'Download failed at {size} bytes') from exc
+        for i, url in enumerate(urls, 1):
+            path = config.get_cache_path(url)
+            logger.info('download url: %s', url)
+            logger.info('download cache path: %s', path)
+            try:
+                with open(path, 'wb') as f:
+                    progress.set(status='Requesting', count=0)
+                    with requests.get(url, stream=True, timeout=TIMEOUT) as response:
+                        response.raise_for_status()
+                        size = int(response.headers.get('Content-Length', 0))
+                        progress.set(total=size, status='Receiving')
+                        for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
+                            if chunk:
+                                f.write(chunk)
+                            progress.update(len(chunk))
+                        progress.set(status='Complete')
+            except requests.exceptions.RequestException as exc:
+                path.unlink(True)
+                count = progress.kwargs['count']
+                if i == len(urls):
+                    raise wn.Error(f'download failed at {count} bytes') from exc
+                else:
+                    logger.info('download failed at %d bytes; trying next url', count)
+            else:
+                break  # success
+
     except KeyboardInterrupt as exc:
-        path.unlink()
-        raise wn.Error(f'Download cancelled at {size} bytes') from exc
+        path.unlink(True)
+        count = progress.kwargs['count']
+        raise wn.Error(f'download cancelled at {count} bytes') from exc
     except Exception:
-        path.unlink()
+        path.unlink(True)
         raise
+
+    return path
diff --git a/wn/_exceptions.py b/wn/_exceptions.py
index 5539775..f5192c9 100644
--- a/wn/_exceptions.py
+++ b/wn/_exceptions.py
@@ -12,6 +12,16 @@ class DatabaseError(Error):
     __module__ = 'wn'
 
 
+class ConfigurationError(Error):
+    """Raised on invalid configurations."""
+    __module__ = 'wn'
+
+
+class ProjectError(Error):
+    """Raised when a project is not found or on errors defined in the index."""
+    __module__ = 'wn'
+
+
 class WnWarning(Warning):
     """Generic warning class for dubious worndet operations."""
 
diff --git a/wn/_export.py b/wn/_export.py
index 9c58773..6f1c70c 100644
--- a/wn/_export.py
+++ b/wn/_export.py
@@ -1,8 +1,9 @@
 
-from typing import List, Set, Sequence, Optional
+from typing import List, Dict, Set, Tuple, Sequence, Optional, cast
 
 import wn
-from wn._types import AnyPath
+from wn._types import AnyPath, VersionInfo
+from wn._util import version_info
 from wn import lmf
 from wn._queries import (
     find_entries,
@@ -24,6 +25,9 @@
     get_form_tags,
     get_sense_counts,
     get_lexfile,
+    get_lexicon,
+    get_lexicon_dependencies,
+    get_lexicon_extension_bases,
 )
 from wn._core import Lexicon
 
@@ -49,8 +53,13 @@ def export(
 
     """
     _precheck(lexicons)
-    _lexicons = [_export_lexicon(lex) for lex in lexicons]
-    lmf.dump(_lexicons, destination, version=version)
+    assert version in lmf.SUPPORTED_VERSIONS
+    _version = version_info(version)
+    resource: lmf.LexicalResource = {
+        'lmf_version': version,
+        'lexicons': [_export_lexicon(lex, _version) for lex in lexicons]
+    }
+    lmf.dump(resource, destination)
 
 
 def _precheck(lexicons: Sequence[Lexicon]) -> None:
@@ -67,101 +76,149 @@ def _precheck(lexicons: Sequence[Lexicon]) -> None:
         all_ids |= idset
 
 
-def _export_lexicon(lexicon: Lexicon) -> lmf.Lexicon:
-    lexids = (lexicon._id,)
-    return lmf.Lexicon(
-        id=lexicon.id,
-        label=lexicon.label,
-        language=lexicon.language,
-        email=lexicon.email,
-        license=lexicon.license,
-        version=lexicon.version,
-        url=lexicon.url or '',
-        citation=lexicon.citation or '',
-        logo=lexicon.logo or '',
-        lexical_entries=_export_lexical_entries(lexids),
-        synsets=_export_synsets(lexids),
-        syntactic_behaviours=_export_syntactic_behaviours(lexids),
-        meta=lmf.Metadata(**lexicon.metadata())
-    )
+_SBMap = Dict[str, List[Tuple[str, str]]]
 
 
-def _export_lexical_entries(lexids: Sequence[int]) -> List[lmf.LexicalEntry]:
-    LexicalEntry = lmf.LexicalEntry
-    Lemma = lmf.Lemma
-    Form = lmf.Form
+def _export_lexicon(lexicon: Lexicon, version: VersionInfo) -> lmf.Lexicon:
+    lexids = (lexicon._id,)
+
+    # WN-LMF 1.0 lexicons put syntactic behaviours on lexical entries
+    # WN-LMF 1.1 lexicons use a 'subcat' IDREFS attribute
+    sbmap: _SBMap = {}
+    if version < (1, 1):
+        for sbid, frame, sids in find_syntactic_behaviours(lexicon_rowids=lexids):
+            for sid in sids:
+                sbmap.setdefault(sid, []).append((sbid, frame))
+
+    lex: lmf.Lexicon = {
+        'id': lexicon.id,
+        'label': lexicon.label,
+        'language': lexicon.language,
+        'email': lexicon.email,
+        'license': lexicon.license,
+        'version': lexicon.version,
+        'url': lexicon.url or '',
+        'citation': lexicon.citation or '',
+        'entries': _export_lexical_entries(lexids, sbmap, version),
+        'synsets': _export_synsets(lexids, version),
+        'meta': _export_metadata(lexicon._id, 'lexicons'),
+    }
+    if version >= (1, 1):
+        lex['logo'] = lexicon.logo or ''
+        lex['requires'] = _export_requires(lexicon._id)
+        lex['frames'] = _export_syntactic_behaviours_1_1(lexids)
+
+    return lex
+
+
+def _export_requires(lexid: int) -> List[lmf.Dependency]:
     return [
-        LexicalEntry(
-            id,
-            Lemma(
-                forms[0][0],
-                pos,
-                script=forms[0][2] or '',
-                pronunciations=_export_pronunciations(forms[0][3]),
-                tags=_export_tags(forms[0][3])
-            ),
-            forms=[Form(fid,
-                        form,
-                        script or '',
-                        pronunciations=_export_pronunciations(frowid),
-                        tags=_export_tags(frowid))
-                   for form, fid, script, frowid in forms[1:]],
-            senses=_export_senses(rowid, lexids),
-            meta=_export_metadata(rowid, 'entries'),
-        )
-        for id, pos, forms, _, rowid
-        in find_entries(lexicon_rowids=lexids)
+        {'id': id, 'version': version, 'url': url}
+        for id, version, url, _ in get_lexicon_dependencies(lexid)
     ]
 
 
-def _export_pronunciations(rowid: int) -> List[lmf.Pronunciation]:
-    Pron = lmf.Pronunciation
-    return [Pron(*data) for data in get_form_pronunciations(rowid)]
+def _export_extends(lexid: int) -> lmf.Dependency:
+    ext_lexid = get_lexicon_extension_bases(lexid, depth=1)[0]
+    _, id, _, _, _, _, version, url, *_ = get_lexicon(ext_lexid)
+    return {'id': id, 'version': version, 'url': url}
+
+
+def _export_lexical_entries(
+    lexids: Sequence[int],
+    sbmap: _SBMap,
+    version: VersionInfo
+) -> List[lmf.LexicalEntry]:
+    entries: List[lmf.LexicalEntry] = []
+    for id, pos, forms, _, rowid in find_entries(lexicon_rowids=lexids):
+        entry: lmf.LexicalEntry = {
+            'id': id,
+            'lemma': {
+                'writtenForm': forms[0][0],
+                'partOfSpeech': pos,
+                'script': forms[0][2] or '',
+                'tags': _export_tags(forms[0][3]),
+            },
+            'forms': [],
+            'senses': _export_senses(rowid, lexids, sbmap, version),
+            'meta': _export_metadata(rowid, 'entries'),
+        }
+        if version >= (1, 1):
+            entry['lemma']['pronunciations'] = _export_pronunciations(forms[0][3])
+        for form, fid, script, frowid in forms[1:]:
+            _form: lmf.Form = {
+                'id': fid or '',
+                'writtenForm': form,
+                'script': script or '',
+                'tags': _export_tags(frowid),
+            }
+            if version >= (1, 1):
+                _form['pronunciations'] = _export_pronunciations(frowid)
+            entry['forms'].append(_form)
+        if version < (1, 1):
+            entry['frames'] = _export_syntactic_behaviours_1_0(entry, sbmap)
+        entries.append(entry)
+    return entries
 
 
-def _export_tags(rowid: int) -> List[lmf.Tag]:
-    Tag = lmf.Tag
-    return [Tag(text, category) for text, category in get_form_tags(rowid)]
+def _export_pronunciations(rowid: int) -> List[lmf.Pronunciation]:
+    return [
+        {'text': text,
+         'variety': variety,
+         'notation': notation,
+         'phonemic': phonemic,
+         'audio': audio}
+        for text, variety, notation, phonemic, audio
+        in get_form_pronunciations(rowid)
+    ]
 
 
-def _export_senses(entry_rowid: int, lexids: Sequence[int]) -> List[lmf.Sense]:
-    Sense = lmf.Sense
+def _export_tags(rowid: int) -> List[lmf.Tag]:
     return [
-        Sense(
-            id,
-            synset,
-            relations=_export_sense_relations(rowid, lexids),
-            examples=_export_examples(rowid, 'senses', lexids),
-            counts=_export_counts(rowid, lexids),
-            lexicalized=get_lexicalized(rowid, 'senses'),
-            adjposition=get_adjposition(rowid) or '',
-            meta=_export_metadata(rowid, 'senses'),
-        )
-        for id, _, synset, _, rowid
-        in get_entry_senses(entry_rowid, lexids)
+        {'text': text, 'category': category}
+        for text, category in get_form_tags(rowid)
     ]
 
 
+def _export_senses(
+    entry_rowid: int,
+    lexids: Sequence[int],
+    sbmap: _SBMap,
+    version: VersionInfo,
+) -> List[lmf.Sense]:
+    senses: List[lmf.Sense] = []
+    for id, _, synset, _, rowid in get_entry_senses(entry_rowid, lexids):
+        sense: lmf.Sense = {
+            'id': id,
+            'synset': synset,
+            'relations': _export_sense_relations(rowid, lexids),
+            'examples': _export_examples(rowid, 'senses', lexids),
+            'counts': _export_counts(rowid, lexids),
+            'lexicalized': get_lexicalized(rowid, 'senses'),
+            'adjposition': get_adjposition(rowid) or '',
+            'meta': _export_metadata(rowid, 'senses'),
+        }
+        if version >= (1, 1) and id in sbmap:
+            sense['subcat'] = sorted(sbid for sbid, _ in sbmap[id])
+        senses.append(sense)
+    return senses
+
+
 def _export_sense_relations(
     sense_rowid: int,
     lexids: Sequence[int]
-) -> List[lmf.SenseRelation]:
-    SenseRelation = lmf.SenseRelation
-    relations = [
-        SenseRelation(
-            id,
-            type,
-            meta=_export_metadata(rowid, 'sense_relations')
-        )
+) -> List[lmf.Relation]:
+    relations: List[lmf.Relation] = [
+        {'target': id,
+         'relType': type,
+         'meta': _export_metadata(rowid, 'sense_relations')}
         for type, rowid, id, *_
         in get_sense_relations(sense_rowid, '*', lexids)
     ]
     relations.extend(
-        SenseRelation(
-            id,
-            type,
-            meta=_export_metadata(rowid, 'sense_synset_relations')
-        )
+        {'target': id,
+         'relType': type,
+         'meta': _export_metadata(rowid, 'sense_synset_relations')}
         for type, rowid, id, *_
         in get_sense_synset_relations(sense_rowid, '*', lexids)
     )
@@ -169,62 +226,58 @@ def _export_sense_relations(
 
 
 def _export_examples(
-    rowid: int, table: str, lexids: Sequence[int]
+    rowid: int,
+    table: str,
+    lexids: Sequence[int]
 ) -> List[lmf.Example]:
-    Example = lmf.Example
     return [
-        Example(
-            text,
-            language,
-            meta=_export_metadata(rowid, f'{table[:-1]}_examples')
-        )
+        {'text': text,
+         'language': language,
+         'meta': _export_metadata(rowid, f'{table[:-1]}_examples')}
         for text, language, rowid
         in get_examples(rowid, table, lexids)
     ]
 
 
 def _export_counts(rowid: int, lexids: Sequence[int]) -> List[lmf.Count]:
-    Count = lmf.Count
     return [
-        Count(val, meta=_export_metadata(id, 'counts'))
+        {'value': val,
+         'meta': _export_metadata(id, 'counts')}
         for val, id in get_sense_counts(rowid, lexids)
     ]
 
 
-def _export_synsets(lexids: Sequence[int]) -> List[lmf.Synset]:
-    Synset = lmf.Synset
-    synsets = []
+def _export_synsets(lexids: Sequence[int], version: VersionInfo) -> List[lmf.Synset]:
+    synsets: List[lmf.Synset] = []
     for id, pos, ili, _, rowid in find_synsets(lexicon_rowids=lexids):
         ilidef = _export_ili_definition(rowid)
         if ilidef and not ili:
             ili = 'in'  # special case for proposed ILIs
-        synsets.append(
-            Synset(
-                id,
-                ili or '',
-                pos,
-                definitions=_export_definitions(rowid, lexids),
-                ili_definition=ilidef,
-                relations=_export_synset_relations(rowid, lexids),
-                examples=_export_examples(rowid, 'synsets', lexids),
-                lexicalized=get_lexicalized(rowid, 'synsets'),
-                members=[row[0] for row in get_synset_members(rowid, lexids)],
-                lexfile=get_lexfile(rowid),
-                meta=_export_metadata(rowid, 'synsets'),
-            )
-        )
+        ss: lmf.Synset = {
+            'id': id,
+            'ili': ili or '',
+            'partOfSpeech': pos,
+            'definitions': _export_definitions(rowid, lexids),
+            'relations': _export_synset_relations(rowid, lexids),
+            'examples': _export_examples(rowid, 'synsets', lexids),
+            'lexicalized': get_lexicalized(rowid, 'synsets'),
+            'lexfile': get_lexfile(rowid) or '',
+            'meta': _export_metadata(rowid, 'synsets'),
+        }
+        if ilidef:
+            ss['ili_definition'] = ilidef
+        if version >= (1, 1):
+            ss['members'] = [row[0] for row in get_synset_members(rowid, lexids)]
+        synsets.append(ss)
     return synsets
 
 
 def _export_definitions(rowid: int, lexids: Sequence[int]) -> List[lmf.Definition]:
-    Definition = lmf.Definition
     return [
-        Definition(
-            text,
-            language,
-            source_sense=sense_id,
-            meta=_export_metadata(rowid, 'definitions')
-        )
+        {'text': text,
+         'language': language,
+         'sourceSense': sense_id,
+         'meta': _export_metadata(rowid, 'definitions')}
         for text, language, sense_id, rowid
         in get_definitions(rowid, lexids)
     ]
@@ -233,39 +286,56 @@ def _export_definitions(rowid: int, lexids: Sequence[int]) -> List[lmf.Definitio
 def _export_ili_definition(synset_rowid: int) -> Optional[lmf.ILIDefinition]:
     _, _, defn, rowid = next(find_proposed_ilis(synset_rowid=synset_rowid),
                              (None, None, None, None))
-    ilidef = None
+    ilidef: Optional[lmf.ILIDefinition] = None
     if defn:
         meta = None
         if rowid is not None:
             meta = _export_metadata(rowid, 'proposed_ilis')
-        ilidef = lmf.ILIDefinition(defn or '', meta=meta)
+        ilidef = {'text': defn, 'meta': meta}
     return ilidef
 
 
 def _export_synset_relations(
     synset_rowid: int,
     lexids: Sequence[int]
-) -> List[lmf.SynsetRelation]:
-    SynsetRelation = lmf.SynsetRelation
-    relations = [
-        SynsetRelation(
-            id,
-            type,
-            meta=_export_metadata(rowid, 'synset_relations')
-        )
+) -> List[lmf.Relation]:
+    return [
+        {'target': id,
+         'relType': type,
+         'meta': _export_metadata(rowid, 'synset_relations')}
         for type, rowid, id, *_
         in get_synset_relations((synset_rowid,), '*', lexids)
     ]
-    return relations
 
 
-def _export_syntactic_behaviours(lexids: Sequence[int]) -> List[lmf.SyntacticBehaviour]:
-    SyntacticBehaviour = lmf.SyntacticBehaviour
+def _export_syntactic_behaviours_1_0(
+    entry: lmf.LexicalEntry,
+    sbmap: _SBMap,
+) -> List[lmf.SyntacticBehaviour]:
+    frames: List[lmf.SyntacticBehaviour] = []
+    sense_ids = {s['id'] for s in entry.get('senses', [])}
+    sbs: Dict[str, Set[str]] = {}
+    for sid in sense_ids:
+        for _, subcat_frame in sbmap.get(sid, []):
+            sbs.setdefault(subcat_frame, set()).add(sid)
+    for subcat_frame, sids in sbs.items():
+        frame: lmf.SyntacticBehaviour = {
+            'subcategorizationFrame': subcat_frame,
+            'senses': sorted(sids),
+        }
+        frames.append(frame)
+    return frames
+
+
+def _export_syntactic_behaviours_1_1(
+    lexids: Sequence[int]
+) -> List[lmf.SyntacticBehaviour]:
     return [
-        SyntacticBehaviour(id, frame, senses)
-        for id, frame, senses in find_syntactic_behaviours(lexicon_rowids=lexids)
+        {'id': id or '',
+         'subcategorizationFrame': frame}
+        for id, frame, _ in find_syntactic_behaviours(lexicon_rowids=lexids)
     ]
 
 
 def _export_metadata(rowid: int, table: str) -> lmf.Metadata:
-    return lmf.Metadata(**get_metadata(rowid, table))
+    return cast(lmf.Metadata, get_metadata(rowid, table))
diff --git a/wn/_queries.py b/wn/_queries.py
index 94595b1..60c0058 100644
--- a/wn/_queries.py
+++ b/wn/_queries.py
@@ -3,7 +3,7 @@
 """
 
 from typing import (
-    Optional, Dict, Set, List, Tuple, Collection, Iterator, Sequence
+    Optional, List, Tuple, Collection, Iterator, Sequence
 )
 import itertools
 import sqlite3
@@ -83,18 +83,29 @@ def find_lexicons(
     lexicon: str,
     lang: str = None,
 ) -> Iterator[_Lexicon]:
-    conn = connect()
-    rows = conn.execute('SELECT rowid, id, version, language FROM lexicons').fetchall()
-    rowids = _get_lexicon_rowids_for_lang(rows, lang)
-    # the next call is somewhat expensive, so try to skip it in a common case
-    if lexicon != '*':
-        rowids &= _get_lexicon_rowids_for_lexicon(rows, lexicon)
-    if rows and not rowids:
+    cur = connect().cursor()
+    found = False
+    for specifier in lexicon.split():
+        limit = '-1' if '*' in lexicon else '1'
+        if ':' not in specifier:
+            specifier += ':*'
+        query = f'''
+            SELECT DISTINCT rowid, id, label, language, email, license,
+                            version, url, citation, logo
+              FROM lexicons
+             WHERE id || ":" || version GLOB :specifier
+               AND (:language ISNULL OR language = :language)
+             LIMIT {limit}
+        '''
+        params = {'specifier': specifier, 'language': lang}
+        for row in cur.execute(query, params):
+            yield row
+            found = True
+    # only raise an error when the query specifies something
+    if not found and (lexicon != '*' or lang is not None):
         raise wn.Error(
             f'no lexicon found with lang={lang!r} and lexicon={lexicon!r}'
         )
-    for rowid in sorted(rowids):
-        yield _get_lexicon(conn, rowid)
 
 
 def get_lexicon(rowid: int) -> _Lexicon:
@@ -115,53 +126,6 @@ def _get_lexicon(conn: sqlite3.Connection, rowid: int) -> _Lexicon:
     return row
 
 
-def _get_lexicon_rowids_for_lang(
-        rows: List[Tuple[int, str, str, str]], lang: Optional[str]
-) -> Set[int]:
-    lg_match: Set[int] = set()
-    if lang:
-        lg_match.update(rowid for rowid, _, _, language in rows if language == lang)
-        if not lg_match:
-            raise wn.Error(f"no lexicon found with language code '{lang}'")
-    else:
-        lg_match.update(row[0] for row in rows)
-    return lg_match
-
-
-def _get_lexicon_rowids_for_lexicon(
-    rows: List[Tuple[int, str, str, str]],
-    lexicon: str,
-) -> Set[int]:
-    lexmap: Dict[str, Dict[str, int]] = {}
-    for rowid, id, version, _ in rows:
-        lexmap.setdefault(id, {})[version] = rowid
-
-    lex_match: Set[int] = set()
-    for id_ver in lexicon.split():
-        id, _, ver = id_ver.partition(':')
-
-        if id == '*':
-            for vermap in lexmap.values():
-                for version, rowid in vermap.items():
-                    if ver in ('', '*', version):
-                        lex_match.add(rowid)
-
-        elif id in lexmap:
-            if ver == '*':
-                lex_match.update(rowid for rowid in lexmap[id].values())
-            elif ver == '':
-                lex_match.add(max(lexmap[id].values()))  # last installed version
-            elif ver in lexmap[id]:
-                lex_match.add(lexmap[id][ver])
-            else:
-                raise wn.Error(f"no lexicon with id '{id}' found with version '{ver}'")
-
-        else:
-            raise wn.Error(f"no lexicon found with id '{id}'")
-
-    return lex_match
-
-
 def get_modified(rowid: int) -> bool:
     query = 'SELECT modified FROM lexicons WHERE rowid = ?'
     return connect().execute(query, (rowid,)).fetchone()[0]
diff --git a/wn/_types.py b/wn/_types.py
index ef153fb..d6e5800 100644
--- a/wn/_types.py
+++ b/wn/_types.py
@@ -1,12 +1,15 @@
 
 from typing import (
-    Optional, Union, Callable, Mapping, Sequence, Dict, Set, Any,
+    Optional, Union, Callable, Mapping, Sequence, Tuple, Dict, Set, Any,
 )
 from pathlib import Path
 
 # For functions taking a filesystem path as a str or a pathlib.Path
 AnyPath = Union[str, Path]
 
+# LMF versions for comparison
+VersionInfo = Tuple[int, ...]
+
 # Synset and Sense relations map a relation type to one or more ids
 RelationMap = Mapping[str, Sequence[str]]
 
diff --git a/wn/_util.py b/wn/_util.py
index 147bec2..7bdede8 100644
--- a/wn/_util.py
+++ b/wn/_util.py
@@ -12,6 +12,13 @@
     import importlib_resources as resources  # noqa: F401
 
 
+from wn._types import VersionInfo
+
+
+def version_info(version_string: str) -> VersionInfo:
+    return tuple(map(int, version_string.split('.')))
+
+
 def is_url(string: str) -> bool:
     """Return True if *string* appears to be a URL."""
     # TODO: ETags?
diff --git a/wn/constants.py b/wn/constants.py
index c4d9f41..d5dbcc1 100644
--- a/wn/constants.py
+++ b/wn/constants.py
@@ -132,6 +132,101 @@
 ])
 
 
+REVERSE_RELATIONS = {
+    'hypernym': 'hyponym',
+    'hyponym': 'hypernym',
+    'instance_hypernym': 'instance_hyponym',
+    'instance_hyponym': 'instance_hypernym',
+    'antonym': 'antonym',
+    'eq_synonym': 'eq_synonym',
+    'similar': 'similar',
+    'meronym': 'holonym',
+    'holonym': 'meronym',
+    'mero_location': 'holo_location',
+    'holo_location': 'mero_location',
+    'mero_member': 'holo_member',
+    'holo_member': 'mero_member',
+    'mero_part': 'holo_part',
+    'holo_part': 'mero_part',
+    'mero_portion': 'holo_portion',
+    'holo_portion': 'mero_portion',
+    'mero_substance': 'holo_substance',
+    'holo_substance': 'mero_substance',
+    'also': 'also',
+    'state_of': 'be_in_state',
+    'be_in_state': 'state_of',
+    'causes': 'is_caused_by',
+    'is_caused_by': 'causes',
+    'subevent': 'is_subevent_of',
+    'is_subevent_of': 'subevent',
+    'manner_of': 'in_manner',
+    'in_manner': 'manner_of',
+    'attribute': 'attribute',
+    'restricts': 'restricted_by',
+    'restricted_by': 'restricts',
+    'classifies': 'classified_by',
+    'classified_by': 'classifies',
+    'entails': 'is_entailed_by',
+    'is_entailed_by': 'entails',
+    'domain_topic': 'has_domain_topic',
+    'has_domain_topic': 'domain_topic',
+    'domain_region': 'has_domain_region',
+    'has_domain_region': 'domain_region',
+    'exemplifies': 'is_exemplified_by',
+    'is_exemplified_by': 'exemplifies',
+    'role': 'involved',
+    'involved': 'role',
+    'agent': 'involved_agent',
+    'involved_agent': 'agent',
+    'patient': 'involved_patient',
+    'involved_patient': 'patient',
+    'result': 'involved_result',
+    'involved_result': 'result',
+    'instrument': 'involved_instrument',
+    'involved_instrument': 'instrument',
+    'location': 'involved_location',
+    'involved_location': 'location',
+    'direction': 'involved_direction',
+    'involved_direction': 'direction',
+    'target_direction': 'involved_target_direction',
+    'involved_target_direction': 'target_direction',
+    'source_direction': 'involved_source_direction',
+    'involved_source_direction': 'source_direction',
+    'co_role': 'co_role',
+    'co_agent_patient': 'co_patient_agent',
+    'co_patient_agent': 'co_agent_patient',
+    'co_agent_instrument': 'co_instrument_agent',
+    'co_instrument_agent': 'co_agent_instrument',
+    'co_agent_result': 'co_result_agent',
+    'co_result_agent': 'co_agent_result',
+    'co_patient_instrument': 'co_instrument_patient',
+    'co_instrument_patient': 'co_patient_instrument',
+    'co_result_instrument': 'co_instrument_result',
+    'co_instrument_result': 'co_result_instrument',
+    'pertainym': 'pertainym',
+    'derivation': 'derivation',
+    'simple_aspect_ip': 'simple_aspect_pi',
+    'simple_aspect_pi': 'simple_aspect_ip',
+    'secondary_aspect_ip': 'secondary_aspect_pi',
+    'secondary_aspect_pi': 'secondary_aspect_ip',
+    'feminine': 'has_feminine',
+    'has_feminine': 'feminine',
+    'masculine': 'has_masculine',
+    'has_masculine': 'masculine',
+    'young': 'has_young',
+    'has_young': 'young',
+    'diminutive': 'has_diminutive',
+    'has_diminutive': 'diminutive',
+    'augmentative': 'has_augmentative',
+    'has_augmentative': 'augmentative',
+    'anto_gradable': 'anto_gradable',
+    'anto_simple': 'anto_simple',
+    'anto_converse': 'anto_converse',
+    'ir_synonym': 'ir_synonym',
+    # 'participle': '',
+    # 'other': '',
+}
+
 # Adjective Positions
 
 ADJPOSITIONS = frozenset((
diff --git a/wn/index.toml b/wn/index.toml
index 7c1de0c..0a1df26 100644
--- a/wn/index.toml
+++ b/wn/index.toml
@@ -5,285 +5,414 @@
   [cili.versions."1.0"]
     url = "https://github.com/globalwordnet/cili/releases/download/v1.0/cili.tsv.xz"
 
+
+[oewn]
+  label = "Open English WordNet"
+  language = "en"
+  license = "https://creativecommons.org/licenses/by/4.0/"
+  [oewn.versions.2021]
+    url = "https://en-word.net/static/english-wordnet-2021.xml.gz"
+  [oewn.versions.2020]
+    error = "Use 'ewn' as the ID prior to version 2021 ('ewn:2020')"
+  [oewn.versions.2019]
+    error = "Use 'ewn' as the ID prior to version 2021 ('ewn:2019')"
+
+
 [ewn]
   label = "Open English WordNet"
   language = "en"
   license = "https://creativecommons.org/licenses/by/4.0/"
+  [ewn.versions.2021]
+    error = "Use 'oewn' as the ID from version 2021 ('oewn:2021')"
   [ewn.versions.2020]
     url = "https://en-word.net/static/english-wordnet-2020.xml.gz"
   [ewn.versions.2019]
     url = "https://en-word.net/static/english-wordnet-2019.xml.gz"
 
 
-[pwn]
-  label = "Princeton WordNet"
-  language = "en"
-  license = "https://wordnet.princeton.edu/license-and-commercial-use"
-  [pwn.versions."3.1"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/pwn31.tar.xz"
-  [pwn.versions."3.0"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/pwn30.tar.xz"
-
-
 [odenet]
   label = "Open German WordNet"
   language = "de"
   license = "https://creativecommons.org/licenses/by-sa/4.0/"
+  [odenet.versions."1.4"]
+    url = "https://github.com/hdaSprachtechnologie/odenet/releases/download/v1.4/odenet-1.4.tar.xz"
   [odenet.versions."1.3"]
     url = "https://github.com/hdaSprachtechnologie/odenet/releases/download/v1.3/odenet-1.3.tar.xz"
 
 
 [omw]
-  label = "Open Multilingual Wordnet v1.3"
+  label = "Open Multilingual Wordnet"
   language = "mul"
   license = "Please consult the LICENSE files included with the individual wordnets. Note that all permit redistribution."
+  [omw.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-1.4.tar.xz"
   [omw.versions."1.3"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/omw-1.3.tar.xz"
+    error = "OMW 1.3 is no longer indexed; See https://github.com/goodmami/wn#changes-to-the-index"
 
 
-[iwn]
-  label = "Italian Wordnet"
-  language = "it"
-  license = "http://opendefinition.org/licenses/odc-by/"
-  [iwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/iwn.tar.xz"
+[omw-en]
+  label = "OMW English Wordnet based on WordNet 3.0"
+  language = "en"
+  license = "https://wordnet.princeton.edu/license-and-commercial-use"
+  [omw-en.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-en-1.4.tar.xz"
 
 
-[ronwn]
-  label = "Romanian Wordnet"
-  language = "ro"
-  license = "https://creativecommons.org/licenses/by-sa/"
-  [ronwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/ron.tar.xz"
+[omw-en31]
+  label = "OMW English Wordnet based on WordNet 3.1"
+  language = "en"
+  license = "https://wordnet.princeton.edu/license-and-commercial-use"
+  [omw-en31.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-en31-1.4.tar.xz"
 
 
-[nobwn]
-  label = "Norwegian Wordnet"
-  language = "nb"
-  license = "wordnet"
-  [nobwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/nob.tar.xz"
+[omw-arb]
+  label = "Arabic WordNet (AWN v2)"
+  language = "arb"
+  license = "https://creativecommons.org/licenses/by-sa/3.0/"
+  [omw-arb.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-arb-1.4.tar.xz"
 
 
-[zsmwn]
-  label = "Wordnet Bahasa"
-  language = "zsm"
-  license = "https://opensource.org/licenses/MIT/"
-  [zsmwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/zsm.tar.xz"
+[omw-bg]
+  label = "BulTreeBank Wordnet (BTB-WN)"
+  language = "bg"
+  license = "https://creativecommons.org/licenses/by/3.0/"
+  [omw-bg.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-bg-1.4.tar.xz"
 
 
-[hrvwn]
-  label = "Croatian Wordnet"
-  language = "hr"
+[omw-ca]
+  label = "Multilingual Central Repository (Catalan)"
+  language = "ca"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [hrvwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/hrv.tar.xz"
+  [omw-ca.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-ca-1.4.tar.xz"
 
 
-[nnown]
-  label = "Norwegian Wordnet"
-  language = "nn"
+[omw-cmn]
+  label = "Chinese Open Wordnet"
+  language = "cmn-Hans"
   license = "wordnet"
-  [nnown.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/nno.tar.xz"
+  [omw-cmn.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-cmn-1.4.tar.xz"
 
 
-[arbwn]
-  label = "Arabic WordNet (AWN v2)"
-  language = "arb"
-  license = "https://creativecommons.org/licenses/by-sa/3.0/"
-  [arbwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/arb.tar.xz"
-
-
-[indwn]
-  label = "Wordnet Bahasa"
-  language = "id"
-  license = "https://opensource.org/licenses/MIT/"
-  [indwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/ind.tar.xz"
+[omw-da]
+  label = "DanNet"
+  language = "da"
+  license = "wordnet"
+  [omw-da.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-da-1.4.tar.xz"
 
 
-[ellwn]
+[omw-el]
   label = "Greek Wordnet"
   language = "el"
   license = "https://opensource.org/licenses/Apache-2.0"
-  [ellwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/ell.tar.xz"
+  [omw-el.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-el-1.4.tar.xz"
 
 
-[glgwn]
-  label = "Multilingual Central Repository"
-  language = "gl"
+[omw-es]
+  label = "Multilingual Central Repository (Spanish)"
+  language = "es"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [glgwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/glg.tar.xz"
+  [omw-es.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-es-1.4.tar.xz"
 
 
-[danwn]
-  label = "DanNet"
-  language = "da"
-  license = "wordnet"
-  [danwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/dan.tar.xz"
+[omw-eu]
+  label = "Multilingual Central Repository (Basque)"
+  language = "eu"
+  license = "https://creativecommons.org/licenses/by/3.0/"
+  [omw-eu.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-eu-1.4.tar.xz"
 
 
-[finwn]
+[omw-fi]
   label = "FinnWordNet"
   language = "fi"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [finwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/fin.tar.xz"
+  [omw-fi.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-fi-1.4.tar.xz"
 
 
-[thawn]
-  label = "Thai Wordnet"
-  language = "th"
-  license = "wordnet"
-  [thawn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/tha.tar.xz"
+[omw-fr]
+  label = "WOLF (Wordnet Libre du Français)"
+  language = "fr"
+  license = "http://www.cecill.info/licenses/Licence_CeCILL-C_V1-en.html"
+  [omw-fr.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-fr-1.4.tar.xz"
 
 
-[catwn]
-  label = "Multilingual Central Repository"
-  language = "ca"
+[omw-gl]
+  label = "Multilingual Central Repository (Galician)"
+  language = "gl"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [catwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/cat.tar.xz"
+  [omw-gl.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-gl-1.4.tar.xz"
 
 
-[frawn]
-  label = "WOLF (Wordnet Libre du Français)"
-  language = "fr"
-  license = "http://www.cecill.info/licenses/Licence_CeCILL-C_V1-en.html"
-  [frawn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/fra.tar.xz"
+[omw-he]
+  label = "Hebrew Wordnet"
+  language = "he"
+  license = "wordnet"
+  [omw-he.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-he-1.4.tar.xz"
 
 
-[slvwn]
-  label = "sloWNet"
-  language = "sl"
-  license = "https://creativecommons.org/licenses/by-sa/3.0/"
-  [slvwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/slv.tar.xz"
+[omw-hr]
+  label = "Croatian Wordnet"
+  language = "hr"
+  license = "https://creativecommons.org/licenses/by/3.0/"
+  [omw-hr.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-hr-1.4.tar.xz"
 
 
-[islwn]
+[omw-id]
+  label = "Wordnet Bahasa (Indonesian)"
+  language = "id"
+  license = "https://opensource.org/licenses/MIT/"
+  [omw-id.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-id-1.4.tar.xz"
+
+
+[omw-is]
   label = "IceWordNet"
   language = "is"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [islwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/isl.tar.xz"
+  [omw-is.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-is-1.4.tar.xz"
 
 
-[euswn]
-  label = "Multilingual Central Repository"
-  language = "eu"
+[omw-it]
+  label = "MultiWordNet (Italian)"
+  language = "it"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [euswn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/eus.tar.xz"
+  [omw-it.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-it-1.4.tar.xz"
 
 
-[slkwn]
-  label = "Slovak WordNet"
-  language = "sk"
-  license = "https://creativecommons.org/licenses/by-sa/3.0/"
-  [slkwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/slk.tar.xz"
+[omw-iwn]
+  label = "ItalWordNet"
+  language = "it"
+  license = "http://opendefinition.org/licenses/odc-by/"
+  [omw-iwn.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-iwn-1.4.tar.xz"
 
 
-[litwn]
+[omw-ja]
+  label = "Japanese Wordnet"
+  language = "ja"
+  license = "wordnet"
+  [omw-ja.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-ja-1.4.tar.xz"
+
+
+[omw-lt]
   label = "Lithuanian  WordNet"
   language = "lt"
   license = "https://creativecommons.org/licenses/by-sa/3.0/"
-  [litwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/lit.tar.xz"
+  [omw-lt.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-lt-1.4.tar.xz"
 
 
-[bulwn]
-  label = "BulTreeBank Wordnet (BTB-WN)"
-  language = "bg"
-  license = "https://creativecommons.org/licenses/by/3.0/"
-  [bulwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/bul.tar.xz"
+[omw-nb]
+  label = "Norwegian Wordnet (Bokmål)"
+  language = "nb"
+  license = "wordnet"
+  [omw-nb.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-nb-1.4.tar.xz"
 
 
-[jpnwn]
-  label = "Japanese Wordnet"
-  language = "jp"
+[omw-nl]
+  label = "Open Dutch WordNet"
+  language = "nl"
+  license = "https://creativecommons.org/licenses/by-sa/4.0/"
+  [omw-nl.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-nl-1.4.tar.xz"
+
+
+[omw-nn]
+  label = "Norwegian Wordnet (Nynorsk)"
+  language = "nn"
   license = "wordnet"
-  [jpnwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/jpn.tar.xz"
+  [omw-nn.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-nn-1.4.tar.xz"
 
 
-[itawn]
-  label = "MultiWordNet"
-  language = "it"
-  license = "https://creativecommons.org/licenses/by/3.0/"
-  [itawn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/ita.tar.xz"
+[omw-pl]
+  label = "plWordNet"
+  language = "pl"
+  license = "wordnet"
+  [omw-pl.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-pl-1.4.tar.xz"
 
 
-[porwn]
+[omw-pt]
   label = "OpenWN-PT"
   language = "pt"
   license = "https://creativecommons.org/licenses/by-sa/"
-  [porwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/por.tar.xz"
+  [omw-pt.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-pt-1.4.tar.xz"
 
 
-[swewn]
-  label = "Swedish (SALDO)"
-  language = "sv"
-  license = "https://creativecommons.org/licenses/by/3.0/"
-  [swewn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/swe.tar.xz"
+[omw-ro]
+  label = "Romanian Wordnet"
+  language = "ro"
+  license = "https://creativecommons.org/licenses/by-sa/"
+  [omw-ro.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-ro-1.4.tar.xz"
 
 
-[alswn]
+[omw-sk]
+  label = "Slovak WordNet"
+  language = "sk"
+  license = "https://creativecommons.org/licenses/by-sa/3.0/"
+  [omw-sk.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-sk-1.4.tar.xz"
+
+
+[omw-sl]
+  label = "sloWNet"
+  language = "sl"
+  license = "https://creativecommons.org/licenses/by-sa/3.0/"
+  [omw-sl.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-sl-1.4.tar.xz"
+
+
+[omw-sq]
   label = "Albanet"
-  language = "als"
+  language = "sq"
   license = "https://creativecommons.org/licenses/by/3.0/"
-  [alswn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/als.tar.xz"
+  [omw-sq.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-sq-1.4.tar.xz"
 
 
-[hebwn]
-  label = "Hebrew Wordnet"
-  language = "he"
-  license = "wordnet"
-  [hebwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/heb.tar.xz"
+[omw-sv]
+  label = "WordNet-SALDO"
+  language = "sv"
+  license = "https://creativecommons.org/licenses/by/3.0/"
+  [omw-sv.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-sv-1.4.tar.xz"
 
 
-[cmnwn]
-  label = "Chinese Open Wordnet"
-  language = "zh"
+[omw-th]
+  label = "Thai Wordnet"
+  language = "th"
   license = "wordnet"
-  [cmnwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/cmn.tar.xz"
+  [omw-th.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-th-1.4.tar.xz"
 
 
-[nldwn]
-  label = "Open Dutch WordNet"
-  language = "nl"
+[omw-zsm]
+  label = "Wordnet Bahasa (Malaysian)"
+  language = "zsm"
+  license = "https://opensource.org/licenses/MIT/"
+  [omw-zsm.versions."1.4"]
+    url = "https://github.com/omwn/omw-data/releases/download/v1.4/omw-zsm-1.4.tar.xz"
+
+
+[own]
+  label = "Open Wordnets for Portuguese and English"
+  language = "mul"
+  license = "Please consult the LICENSE files."
+  [own.versions."1.0.0"]
+    url = "https://github.com/own-pt/openWordnet-PT/releases/download/v1.0.0/own.tar.gz"
+
+
+[own-en]
+  label = "Open Wordnet for English"
+  language = "en"
+  license = "Please consult the LICENSE files."
+  [own-en.versions."1.0.0"]
+    url = "https://github.com/own-pt/openWordnet-PT/releases/download/v1.0.0/own-en.tar.gz"
+
+
+[own-pt]
+  label = "Open Wordnet for Portuguese"
+  language = "pt"
+  license = "Please consult the LICENSE files."
+  [own-pt.versions."1.0.0"]
+    url = "https://github.com/own-pt/openWordnet-PT/releases/download/v1.0.0/own-pt.tar.gz"
+
+
+[kurdnet]
+  label = "KurdNet (Kurdish WordNet)"
+  language = "ckb"
   license = "https://creativecommons.org/licenses/by-sa/4.0/"
-  [nldwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/nld.tar.xz"
+  [kurdnet.versions."1.0"]
+    url = "https://github.com/sinaahmadi/kurdnet/releases/download/kurdnet-1.0.tar.xz/kurdnet-1.0.tar.xz"
 
 
-[polwn]
-  label = "plWordNet"
-  language = "pl"
-  license = "wordnet"
-  [polwn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/pol.tar.xz"
+# Delisted wordnets
 
+[pwn]
+  [pwn.versions."3.0"]
+    error = "'pwn:3.0' is no longer indexed; use 'omw-en:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+  [pwn.versions."3.1"]
+    error = "'pwn:3.1' is no longer indexed; use 'omw-en31:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
 
+[alswn]
+  error = "'alswn:1.3+omw' is no longer indexed; use 'omw-sq:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[arbwn]
+  error = "'arbwn:1.3+omw' is no longer indexed; use 'omw-arb:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[bulwn]
+  error = "'bulwn:1.3+omw' is no longer indexed; use 'omw-bg:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[catwn]
+  error = "'catwn:1.3+omw' is no longer indexed; use 'omw-ca:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[cmnwn]
+  error = "'cmnwn:1.3+omw' is no longer indexed; use 'omw-cmn:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[danwn]
+  error = "'danwn:1.3+omw' is no longer indexed; use 'omw-da:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[ellwn]
+  error = "'ellwn:1.3+omw' is no longer indexed; use 'omw-el:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[euswn]
+  error = "'euswn:1.3+omw' is no longer indexed; use 'omw-eu:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[finwn]
+  error = "'finwn:1.3+omw' is no longer indexed; use 'omw-fi:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[frawn]
+  error = "'frawn:1.3+omw' is no longer indexed; use 'omw-fr:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[glgwn]
+  error = "'glgwn:1.3+omw' is no longer indexed; use 'omw-gl:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[hebwn]
+  error = "'hebwn:1.3+omw' is no longer indexed; use 'omw-he:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[hrvwn]
+  error = "'hrvwn:1.3+omw' is no longer indexed; use 'omw-hr:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[indwn]
+  error = "'indwn:1.3+omw' is no longer indexed; use 'omw-id:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[islwn]
+  error = "'islwn:1.3+omw' is no longer indexed; use 'omw-is:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[itawn]
+  error = "'itawn:1.3+omw' is no longer indexed; use 'omw-it:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[iwn]
+  error = "'iwn:1.3+omw' is no longer indexed; use 'omw-iwn:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[jpnwn]
+  error = "'jpnwn:1.3+omw' is no longer indexed; use 'omw-ja:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[litwn]
+  error = "'litwn:1.3+omw' is no longer indexed; use 'omw-lt:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[nldwn]
+  error = "'nldwn:1.3+omw' is no longer indexed; use 'omw-nl:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[nnown]
+  error = "'nnown:1.3+omw' is no longer indexed; use 'omw-nn:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[nobwn]
+  error = "'nobwn:1.3+omw' is no longer indexed; use 'omw-nb:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[polwn]
+  error = "'polwn:1.3+omw' is no longer indexed; use 'omw-pl:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[porwn]
+  error = "'porwn:1.3+omw' is no longer indexed; use 'omw-pt:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[ronwn]
+  error = "'ronwn:1.3+omw' is no longer indexed; use 'omw-ro:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[slkwn]
+  error = "'slkwn:1.3+omw' is no longer indexed; use 'omw-sk:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[slvwn]
+  error = "'slvwn:1.3+omw' is no longer indexed; use 'omw-sl:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
 [spawn]
-  label = "Multilingual Central Repository"
-  language = "es"
-  license = "https://creativecommons.org/licenses/by/3.0/"
-  [spawn.versions."1.3+omw"]
-    url = "https://github.com/bond-lab/omw-data/releases/download/v1.3/spa.tar.xz"
+  error = "'spawn:1.3+omw' is no longer indexed; use 'omw-es:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[swewn]
+  error = "'swewn:1.3+omw' is no longer indexed; use 'omw-sv:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[thawn]
+  error = "'thawn:1.3+omw' is no longer indexed; use 'omw-th:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
+[zsmwn]
+  error = "'zsmwn:1.3+omw' is no longer indexed; use 'omw-zsm:1.4' instead (https://github.com/goodmami/wn#changes-to-the-index)"
diff --git a/wn/lmf.py b/wn/lmf.py
index b08b329..d85f6a7 100644
--- a/wn/lmf.py
+++ b/wn/lmf.py
@@ -5,48 +5,33 @@
 
 from typing import (
     Type,
-    Container,
     List,
     Tuple,
     Dict,
-    NamedTuple,
     Optional,
     TextIO,
     BinaryIO,
-    Iterator,
+    Any,
+    Union,
+    cast
 )
+import sys
+if sys.version_info >= (3, 8):
+    from typing import TypedDict, Literal
+else:
+    from typing_extensions import TypedDict, Literal
+import re
 from pathlib import Path
-import warnings
 import xml.etree.ElementTree as ET  # for general XML parsing
 import xml.parsers.expat  # for fast scanning of Lexicon versions
 from xml.sax.saxutils import quoteattr
 
 import wn
-from wn._types import AnyPath
-from wn._util import is_xml
-from wn.constants import (
-    SENSE_RELATIONS,
-    SENSE_SYNSET_RELATIONS,
-    SYNSET_RELATIONS,
-    ADJPOSITIONS,
-    PARTS_OF_SPEECH,
-    LEXICOGRAPHER_FILES,
-)
+from wn._types import AnyPath, VersionInfo
+from wn._util import is_xml, version_info
 from wn.util import ProgressHandler, ProgressBar
 
 
-LEXICON_INFO_ATTRIBUTES = {
-    'LexicalEntry', 'ExternalLexicalEntry',
-    'Lemma', 'Form', 'Pronunciation', 'Tag',
-    'Sense', 'ExternalSense',
-    'SenseRelation', 'Example', 'Count',
-    'SyntacticBehaviour',
-    'Synset', 'ExternalSynset',
-    'Definition',  # 'ILIDefinition',
-    'SynsetRelation'
-}
-
-
 class LMFError(wn.Error):
     """Raised on invalid LMF-XML documents."""
 
@@ -55,6 +40,7 @@ class LMFWarning(Warning):
     """Issued on non-conforming LFM values."""
 
 
+SUPPORTED_VERSIONS = {'1.0', '1.1'}
 _XMLDECL = b'<?xml version="1.0" encoding="UTF-8"?>'
 _DOCTYPE = '<!DOCTYPE LexicalResource SYSTEM "{schema}">'
 _SCHEMAS = {
@@ -85,428 +71,282 @@ class LMFWarning(Warning):
     'title',
     'type',
 ]
-# dublin-core metadata needs the namespace uri prefixed
-_DC_QNAME_PAIRS = {
-    version: [(f'{{{uri}}}{attr}', attr) for attr in _DC_ATTRS]
+_NS_ATTRS = {
+    version: dict(
+        [(f'{uri} {attr}', attr) for attr in _DC_ATTRS]
+        + [('status', 'status'),
+           ('note', 'note'),
+           ('confidenceScore', 'confidenceScore')]
+    )
     for version, uri in _DC_URIS.items()
 }
 
+_LMF_1_0_ELEMS: Dict[str, str] = {
+    'LexicalResource': 'lexical-resource',
+    'Lexicon': 'lexicons',
+    'LexicalEntry': 'entries',
+    'Lemma': 'lemma',
+    'Form': 'forms',
+    'Tag': 'tags',
+    'Sense': 'senses',
+    'SenseRelation': 'relations',
+    'Example': 'examples',
+    'Count': 'counts',
+    'SyntacticBehaviour': 'frames',
+    'Synset': 'synsets',
+    'Definition': 'definitions',
+    'ILIDefinition': 'ili_definition',
+    'SynsetRelation': 'relations',
+}
+_LMF_1_1_ELEMS = dict(_LMF_1_0_ELEMS)
+_LMF_1_1_ELEMS.update({
+    'Requires': 'requires',
+    'Extends': 'extends',
+    'Pronunciation': 'pronunciations',
+    'LexiconExtension': 'lexicons',
+    'ExternalLexicalEntry': 'entries',
+    'ExternalLemma': 'lemma',
+    'ExternalForm': 'forms',
+    'ExternalSense': 'senses',
+    'ExternalSynset': 'synsets',
+})
+_VALID_ELEMS = {
+    '1.0': _LMF_1_0_ELEMS,
+    '1.1': _LMF_1_1_ELEMS,
+}
+_LIST_ELEMS = {  # elements that collect into lists
+    'Lexicon',
+    'LexicalEntry',
+    'Form',
+    'Pronunciation',
+    'Tag',
+    'Sense',
+    'SenseRelation',
+    'Example',
+    'Count',
+    'Synset',
+    'Definition',
+    'SynsetRelation',
+    'SyntacticBehaviour',
+    'LexiconExtension',
+    'Requires',
+    'ExternalLexicalEntry',
+    'ExternalForm',
+    'ExternalSense',
+    'ExternalSynset',
+}
+_CDATA_ELEMS = {  # elements with inner text
+    'Pronunciation',
+    'Tag',
+    'Definition',
+    'ILIDefinition',
+    'Example',
+    'Count',
+}
+_META_ELEMS = {  # elements with metadata
+    'Lexicon',
+    'LexicalEntry',
+    'Sense',
+    'SenseRelation',
+    'Example',
+    'Count',
+    'Synset',
+    'Definition',
+    'ILIDefinition',
+    'SynsetRelation',
+    'LexiconExtension',
+}
+
+
+# WN-LMF Modeling ######################################################
+
+# WN-LMF type-checking is handled via TypedDicts.  Inheritance and
+# `total=False` are used to model optionality. For more information
+# about this tactic, see https://www.python.org/dev/peps/pep-0589/.
+
+class Metadata(TypedDict, total=False):
+    contributor: str
+    coverage: str
+    creator: str
+    date: str
+    description: str
+    format: str
+    identifier: str
+    publisher: str
+    relation: str
+    rights: str
+    source: str
+    subject: str
+    title: str
+    type: str
+    status: str
+    note: str
+    confidenceScore: float
+
+
+_HasId = TypedDict('_HasId', {'id': str})
+_HasVersion = TypedDict('_HasVersion', {'version': str})
+_HasILI = TypedDict('_HasILI', {'ili': str})
+_HasSynset = TypedDict('_HasSynset', {'synset': str})
+_MaybeId = TypedDict('_MaybeId', {'id': str}, total=False)
+_HasText = TypedDict('_HasText', {'text': str})
+_MaybeScript = TypedDict('_MaybeScript', {'script': str}, total=False)
+_HasMeta = TypedDict('_HasMeta', {'meta': Optional[Metadata]})
+_External = TypedDict('_External', {'external': Literal['true']})
+
+
+class ILIDefinition(_HasText, _HasMeta):
+    ...
+
+
+class Definition(_HasText, _HasMeta, total=False):
+    language: str
+    sourceSense: str
+
+
+class Relation(_HasMeta):
+    target: str
+    relType: str
+
+
+class Example(_HasText, _HasMeta, total=False):
+    language: str
+
+
+class Synset(_HasId, _HasILI, _HasMeta, total=False):
+    ili_definition: ILIDefinition
+    partOfSpeech: str
+    definitions: List[Definition]
+    relations: List[Relation]
+    examples: List[Example]
+    lexicalized: bool
+    members: List[str]
+    lexfile: str
+
+
+class ExternalSynset(_HasId, _External, total=False):
+    definitions: List[Definition]
+    relations: List[Relation]
+    examples: List[Example]
 
-class XMLEventIterator:
-    """etree.iterparse() event iterator with lookahead"""
-    def __init__(
-        self,
-        iterator: Iterator[Tuple[str, ET.Element]],
-        progress: ProgressHandler
-    ):
-        self.iterator = iterator
-        self.progress = progress
-        self._next = next(iterator, (None, None))
-
-    def __iter__(self):
-        return self
-
-    def __next__(self):
-        _next = self._next
-        event, elem = _next
-        if _next == (None, None):
-            self.progress.set(status="Complete")
-            raise StopIteration
-        self._next = next(self.iterator, (None, None))
-        return _next
-
-    def starts(self, *tags: str) -> bool:
-        event, elem = self._next
-        if elem is None:
-            return False
-        return event == 'start' and elem.tag in tags
-
-    def start(self, *tags: str) -> ET.Element:
-        event, elem = next(self)
-        if event != 'start':
-            raise LMFError(f'expected <{"|".join(tags)}>, got </{elem.tag}>')
-        if elem.tag not in tags:
-            raise LMFError(f'expected <{"|".join(tags)}>, got <{elem.tag}>')
-        return elem
-
-    def end(self, *tags: str) -> ET.Element:
-        event, elem = next(self)
-        if event != 'end':
-            raise LMFError(f'expected </{"|".join(tags)}>, got <{elem.tag}>')
-        if elem.tag not in tags:
-            raise LMFError(f'expected </{"|".join(tags)}>, got </{elem.tag}>')
-        self.progress.update()
-        return elem
-
-
-class Metadata(NamedTuple):
-    contributor: Optional[str] = None
-    coverage: Optional[str] = None
-    creator: Optional[str] = None
-    date: Optional[str] = None
-    description: Optional[str] = None
-    format: Optional[str] = None
-    identifier: Optional[str] = None
-    publisher: Optional[str] = None
-    relation: Optional[str] = None
-    rights: Optional[str] = None
-    source: Optional[str] = None
-    subject: Optional[str] = None
-    title: Optional[str] = None
-    type: Optional[str] = None
-    status: Optional[str] = None
-    note: Optional[str] = None
-    confidence: Optional[float] = None
-
-
-class _HasMeta:
-    __slots__ = 'meta',
-
-    def __init__(self, meta: Metadata = None):
-        self.meta = meta
-
-
-# These types model the WN-LMF DTD
-# http://globalwordnet.github.io/schemas/WN-LMF-1.0.dtd
 
 class Count(_HasMeta):
-    __slots__ = 'value',
-
-    def __init__(self, value: int, meta: Metadata = None):
-        super().__init__(meta)
-        self.value = value
-
-
-class SyntacticBehaviour:
-    __slots__ = 'id', 'frame', 'senses'
-
-    def __init__(self, id: str, frame: str, senses: List[str] = None):
-        self.id = id
-        self.frame = frame
-        self.senses = senses or []
-
-
-class SenseRelation(_HasMeta):
-    __slots__ = 'target', 'type'
-
-    def __init__(self, target: str, type: str, meta: Metadata = None):
-        super().__init__(meta)
-        self.target = target
-        self.type = type
-
-
-class SynsetRelation(_HasMeta):
-    __slots__ = 'target', 'type'
-
-    def __init__(self, target: str, type: str, meta: Metadata = None):
-        super().__init__(meta)
-        self.target = target
-        self.type = type
-
-
-class Example(_HasMeta):
-    __slots__ = 'text', 'language'
-
-    def __init__(self, text: str, language: str = '', meta: Metadata = None):
-        super().__init__(meta)
-        self.text = text
-        self.language = language
-
-
-class ILIDefinition(_HasMeta):
-    __slots__ = 'text',
-
-    def __init__(self, text: str, meta: Metadata = None):
-        super().__init__(meta)
-        self.text = text
-
-
-class Definition(_HasMeta):
-    __slots__ = 'text', 'language', 'source_sense'
-
-    def __init__(
-            self,
-            text: str,
-            language: str = '',
-            source_sense: str = '',
-            meta: Metadata = None):
-        super().__init__(meta)
-        self.text = text
-        self.language = language
-        self.source_sense = source_sense
-
-
-class Synset(_HasMeta):
-    __slots__ = ('id', 'ili', 'pos', 'definitions', 'ili_definition',
-                 'relations', 'examples', 'lexicalized', 'members',
-                 'lexfile', 'external')
-
-    def __init__(
-        self,
-        id: str,
-        ili: str = None,
-        pos: str = None,
-        definitions: List[Definition] = None,
-        ili_definition: ILIDefinition = None,
-        relations: List[SynsetRelation] = None,
-        examples: List[Example] = None,
-        lexicalized: bool = True,
-        members: List[str] = None,
-        lexfile: str = None,
-        meta: Metadata = None
-    ):
-        super().__init__(meta)
-        self.id = id
-        self.ili = ili
-        self.pos = pos
-        self.definitions = definitions or []
-        self.ili_definition = ili_definition
-        self.relations = relations or []
-        self.examples = examples or []
-        self.lexicalized = lexicalized
-        self.members = members or []
-        self.lexfile = lexfile
-        self.external = False
-
-    @classmethod
-    def as_external(
-        cls,
-        id: str,
-        definitions: List[Definition] = None,
-        relations: List[SynsetRelation] = None,
-        examples: List[Example] = None
-    ):
-        obj = cls(
-            id,
-            '',
-            '',
-            definitions=definitions,
-            relations=relations,
-            examples=examples
-        )
-        obj.external = True
-        return obj
-
-
-class Sense(_HasMeta):
-    __slots__ = ('id', 'synset', 'relations', 'examples', 'counts',
-                 'lexicalized', 'adjposition', 'external')
-
-    def __init__(
-        self,
-        id: str,
-        synset: str,
-        relations: List[SenseRelation] = None,
-        examples: List[Example] = None,
-        counts: List[Count] = None,
-        lexicalized: bool = True,
-        adjposition: str = '',
-        meta: Metadata = None
-    ):
-        super().__init__(meta)
-        self.id = id
-        self.synset = synset
-        self.relations = relations or []
-        self.examples = examples or []
-        self.counts = counts or []
-        self.lexicalized = lexicalized
-        self.adjposition = adjposition
-        self.external = False
-
-    @classmethod
-    def as_external(
-        cls,
-        id: str,
-        relations: List[SenseRelation] = None,
-        examples: List[Example] = None,
-        counts: List[Count] = None
-    ):
-        obj = cls(
-            id,
-            '',
-            relations=relations,
-            examples=examples,
-            counts=counts
-        )
-        obj.external = True
-        return obj
-
-
-class Pronunciation:
-    __slots__ = 'value', 'variety', 'notation', 'phonemic', 'audio'
-
-    def __init__(
-        self,
-        value: str,
-        variety: str = None,
-        notation: str = None,
-        phonemic: bool = True,
-        audio: str = None,
-    ):
-        self.value = value
-        self.variety = variety
-        self.notation = notation
-        self.phonemic = phonemic
-        self.audio = audio
-
-
-class Tag:
-    __slots__ = 'text', 'category'
-
-    def __init__(self, text: str, category: str):
-        self.text = text
-        self.category = category
-
-
-class Form:
-    __slots__ = 'id', 'form', 'script', 'pronunciations', 'tags', 'external'
-
-    def __init__(
-        self,
-        id: Optional[str],
-        form: str,
-        script: str,
-        pronunciations: List[Pronunciation] = None,
-        tags: List[Tag] = None,
-    ):
-        self.id = id
-        self.form = form
-        self.script = script
-        self.pronunciations = pronunciations or []
-        self.tags = tags or []
-        self.external = False
-
-    @classmethod
-    def as_external(
-        cls,
-        id: str,
-        pronunciations: List[Pronunciation] = None,
-        tags: List[Tag] = None,
-    ):
-        obj = cls(
-            id,
-            '',
-            '',
-            pronunciations=pronunciations,
-            tags=tags,
-        )
-        obj.external = True
-        return obj
-
-
-class Lemma:
-    __slots__ = 'form', 'pos', 'script', 'pronunciations', 'tags', 'external'
-
-    def __init__(
-        self,
-        form: str,
-        pos: str,
-        script: str = '',
-        pronunciations: List[Pronunciation] = None,
-        tags: List[Tag] = None,
-    ):
-        self.form = form
-        self.pos = pos
-        self.script = script
-        self.pronunciations = pronunciations or []
-        self.tags = tags or []
-        self.external = False
-
-    @classmethod
-    def as_external(
-        cls,
-        pronunciations: List[Pronunciation] = None,
-        tags: List[Tag] = None,
-    ):
-        obj = cls(
-            '',
-            '',
-            pronunciations=pronunciations,
-            tags=tags,
-        )
-        obj.external = True
-        return obj
-
-
-class LexicalEntry(_HasMeta):
-    __slots__ = 'id', 'lemma', 'forms', 'senses', 'external'
-
-    def __init__(
-        self,
-        id: str,
-        lemma: Optional[Lemma],
-        forms: List[Form] = None,
-        senses: List[Sense] = None,
-        meta: Metadata = None
-    ):
-        super().__init__(meta)
-        self.id = id
-        self.lemma = lemma
-        self.forms = forms or []
-        self.senses = senses or []
-        self.external = False
-
-    @classmethod
-    def as_external(
-        cls,
-        id: str,
-        lemma: Lemma = None,
-        forms: List[Form] = None,
-        senses: List[Sense] = None,
-    ):
-        obj = cls(
-            id,
-            lemma,
-            forms=forms,
-            senses=senses
-        )
-        obj.external = True
-        return obj
-
-
-class Lexicon(_HasMeta):
-    __slots__ = ('id', 'label', 'language',
-                 'email', 'license', 'version', 'url', 'citation', 'logo',
-                 'lexical_entries', 'synsets', 'syntactic_behaviours',
-                 'extends', 'requires')
-
-    def __init__(
-            self,
-            id: str,
-            label: str,
-            language: str,
-            email: str,
-            license: str,
-            version: str,
-            url: str = '',
-            citation: str = '',
-            logo: str = '',
-            lexical_entries: List[LexicalEntry] = None,
-            synsets: List[Synset] = None,
-            syntactic_behaviours: List[SyntacticBehaviour] = None,
-            extends: Dict[str, str] = None,
-            requires: List[Dict[str, str]] = None,
-            meta: Metadata = None):
-        super().__init__(meta)
-        self.id = id
-        self.label = label
-        self.language = language
-        self.email = email
-        self.license = license
-        self.version = version
-        self.url = url
-        self.citation = citation
-        self.logo = logo
-        self.lexical_entries = lexical_entries or []
-        self.synsets = synsets or []
-        self.syntactic_behaviours = syntactic_behaviours or []
-        self.extends = extends
-        self.requires = requires or []
-
-
-LexicalResource = List[Lexicon]
+    value: int
+
+
+class Sense(_HasId, _HasSynset, _HasMeta, total=False):
+    relations: List[Relation]
+    examples: List[Example]
+    counts: List[Count]
+    lexicalized: bool
+    adjposition: str
+    subcat: List[str]
+
+
+class ExternalSense(_HasId, _External, total=False):
+    relations: List[Relation]
+    examples: List[Example]
+    counts: List[Count]
+
+
+class Pronunciation(_HasText, total=False):
+    variety: str
+    notation: str
+    phonemic: bool
+    audio: str
+
+
+class Tag(_HasText):
+    category: str
+
+
+class _FormChildren(TypedDict, total=False):
+    pronunciations: List[Pronunciation]
+    tags: List[Tag]
+
+
+class Lemma(_MaybeScript, _FormChildren):
+    writtenForm: str
+    partOfSpeech: str
+
+
+class ExternalLemma(_FormChildren, _External):
+    ...
+
+
+class Form(_MaybeId, _MaybeScript, _FormChildren):
+    writtenForm: str
+
+
+class ExternalForm(_HasId, _FormChildren, _External):
+    ...
+
+
+class _SyntacticBehaviourBase(_MaybeId):
+    subcategorizationFrame: str
+
+
+class SyntacticBehaviour(_SyntacticBehaviourBase, total=False):
+    senses: List[str]
+
+
+class _LexicalEntryBase(_HasId, _HasMeta, total=False):
+    forms: List[Form]
+    senses: List[Sense]
+    frames: List[SyntacticBehaviour]
+
+
+class LexicalEntry(_LexicalEntryBase):
+    lemma: Lemma
+
+
+class ExternalLexicalEntry(_HasId, _External, total=False):
+    lemma: ExternalLemma
+    forms: List[Union[Form, ExternalForm]]
+    senses: List[Union[Sense, ExternalSense]]
+
+
+class Dependency(_HasId, _HasVersion, total=False):
+    url: str
+
+
+class _LexiconBase(_HasMeta):
+    id: str
+    version: str
+    label: str
+    language: str
+    email: str
+    license: str
+
+
+class Lexicon(_LexiconBase, total=False):
+    url: str
+    citation: str
+    logo: str
+    requires: List[Dependency]
+    entries: List[LexicalEntry]
+    synsets: List[Synset]
+    frames: List[SyntacticBehaviour]
+
+
+class LexiconExtension(_LexiconBase, total=False):
+    url: str
+    citation: str
+    logo: str
+    extends: Dependency
+    requires: List[Dependency]
+    entries: List[Union[LexicalEntry, ExternalLexicalEntry]]
+    synsets: List[Union[Synset, ExternalSynset]]
+    frames: List[SyntacticBehaviour]
+
+
+class LexicalResource(TypedDict):
+    lmf_version: str
+    lexicons: List[Union[Lexicon, LexiconExtension]]
 
 
+# Reading ##############################################################
+
 def is_lmf(source: AnyPath) -> bool:
     """Return True if *source* is a WN-LMF file."""
     source = Path(source).expanduser()
@@ -540,35 +380,31 @@ def scan_lexicons(source: AnyPath) -> List[Dict]:
     """Scan *source* and return only the top-level lexicon info."""
 
     source = Path(source).expanduser()
+    infos: List[Dict] = []
 
-    # this is implemented with expat as it's much faster than etree for
-    # this task
-    infos = []
+    lex_re = re.compile(b'<(Lexicon|LexiconExtension|Extends)\\b([^>]*)>', flags=re.M)
+    attr_re = re.compile(b'''\\b(id|version|label)=["']([^"']+)["']''', flags=re.M)
 
-    def start(name, attrs):
-        if name in ('Lexicon', 'LexiconExtension'):
-            attrs['counts'] = {}
-            infos.append(attrs)
-        elif name == 'Extends':
-            infos[-1]['extends'] = attrs['id'], attrs['version']
-        elif infos:
-            counts = infos[-1]['counts']
-            if name in counts:
-                counts[name] += 1
-            else:
-                counts[name] = 1
-
-    p = xml.parsers.expat.ParserCreate()
-    p.StartElementHandler = start
     with open(source, 'rb') as fh:
-        try:
-            p.ParseFile(fh)
-        except xml.parsers.expat.ExpatError as exc:
-            raise LMFError('invalid or ill-formed WN-LMF file') from exc
+        for m in lex_re.finditer(fh.read()):
+            lextype, remainder = m.groups()
+            info = {_m.group(1).decode('utf-8'): _m.group(2).decode('utf-8')
+                    for _m in attr_re.finditer(remainder)}
+            if 'id' not in info or 'version' not in info:
+                raise LMFError(f'<{lextype.decode("utf-8")}> missing id or version')
+            if lextype != b'Extends':
+                infos.append(info)
+            elif len(infos) > 0:
+                infos[-1]['extends'] = info
+            else:
+                raise LMFError('invalid use of <Extends> in WN-LMF file')
 
     return infos
 
 
+_Elem = Dict[str, Any]  # basic type for the loaded XML data
+
+
 def load(
     source: AnyPath,
     progress_handler: Optional[Type[ProgressHandler]] = ProgressBar
@@ -578,503 +414,309 @@ def load(
     Args:
         source: path to a WN-LMF file
     """
+    source = Path(source).expanduser()
     if progress_handler is None:
         progress_handler = ProgressHandler
 
-    source = Path(source).expanduser()
+    version, num_elements = _quick_scan(source)
+    progress = progress_handler(
+        message='Read', total=num_elements, refresh_interval=10000
+    )
+
+    root: Dict[str, _Elem] = {}
+    parser = _make_parser(root, version, progress)
+
+    with open(source, 'rb') as fh:
+        try:
+            parser.ParseFile(fh)
+        except xml.parsers.expat.ExpatError as exc:
+            raise LMFError('invalid or ill-formed XML') from exc
+
+    progress.close()
+
+    resource: LexicalResource = {
+        'lmf_version': version,
+        'lexicons': [_validate(lex)
+                     for lex in root['lexical-resource'].get('lexicons', [])],
+    }
 
+    return resource
+
+
+def _quick_scan(source: Path) -> Tuple[str, int]:
     with source.open('rb') as fh:
         version = _read_header(fh)
         # _read_header() only reads the first 2 lines
         remainder = fh.read()
-        total_elements = remainder.count(b'</') + remainder.count(b'/>')
+        num_elements = remainder.count(b'</') + remainder.count(b'/>')
+    return version, num_elements
 
-    progress = progress_handler(
-        message='Read', total=total_elements, refresh_interval=10000
-    )
-    events = XMLEventIterator(
-        ET.iterparse(source, events=('start', 'end')),
-        progress
-    )
-    root = events.start('LexicalResource')
 
-    lexicons: List[Lexicon] = []
-    while events.starts('Lexicon', 'LexiconExtension'):
-        lexicons.append(_load_lexicon(events, version))
-        root.clear()
+def _make_parser(root, version, progress):  # noqa: C901
+    stack = [root]
+    ELEMS = _VALID_ELEMS[version]
+    NS_ATTRS = _NS_ATTRS[version]
+    CDATA_ELEMS = _CDATA_ELEMS & set(ELEMS)
+    LIST_ELEMS = _LIST_ELEMS & set(ELEMS)
 
-    events.end(root.tag)
-    list(events)  # consume remaining events, if any
+    p = xml.parsers.expat.ParserCreate(namespace_separator=' ')
+    has_text = False
 
-    return lexicons
+    def start(name, attrs):
+        nonlocal has_text
+        if name in CDATA_ELEMS:
+            has_text = True
+
+        if name in _META_ELEMS:
+            meta = {}
+            for attr in list(attrs):
+                if attr in NS_ATTRS:
+                    meta[NS_ATTRS[attr]] = attrs.pop(attr)
+            attrs['meta'] = meta or None
+
+        if name.startswith('External'):
+            attrs['external'] = True
+
+        parent = stack[-1]
+        key = ELEMS.get(name)
+        if name in LIST_ELEMS:
+            parent.setdefault(key, []).append(attrs)
+        elif key is None or key in parent:
+            raise _unexpected(name, p)
+        else:
+            parent[key] = attrs
 
+        stack.append(attrs)
 
-def _load_lexicon(events, version) -> Lexicon:
-    extends: Optional[Dict[str, str]] = None
-    requires: List[Dict[str, str]] = []
+    def char_data(data):
+        if has_text:
+            parent = stack[-1]
+            # sometimes the buffering occurs in the middle of text; if
+            # so, append the current data
+            if 'text' in parent:
+                parent['text'] += data
+            else:
+                parent['text'] = data
 
-    if version == '1.0':
-        lex_root = events.start('Lexicon')
-        extension = False
-    else:
-        lex_root = events.start('Lexicon', 'LexiconExtension')
-        extension = lex_root.tag == 'LexiconExtension'
-        extends = _load_dependency(events, 'Extends') if extension else None
-        while events.starts('Requires'):
-            requires.append(_load_dependency(events, 'Requires'))
-
-    attrs = lex_root.attrib
-    events.progress.set(message=f'Read {attrs["id"]}:{attrs["version"]}')
-
-    events.progress.set(status='Lexical Entries')
-    entries, frames, sbmap = _load_lexical_entries(
-        events, extension, version, lex_root
-    )
-    events.progress.set(status='Synsets')
-    synsets = _load_synsets(
-        events, extension, version, lex_root
-    )
-    if version != '1.0':
-        frames.extend(_load_syntactic_behaviours(events, version))
-        for sb in frames:
-            sb.senses.extend(sbmap.get(sb.id, []))
-
-    lex = Lexicon(
-        attrs['id'],
-        attrs['label'],
-        attrs['language'],
-        attrs['email'],
-        attrs['license'],
-        attrs['version'],
-        url=attrs.get('url'),
-        citation=attrs.get('citation'),
-        logo=attrs.get('logo'),
-        lexical_entries=entries,
-        synsets=synsets,
-        syntactic_behaviours=frames,
-        extends=extends,
-        requires=requires,
-        meta=_get_metadata(attrs, version),
-    )
-    events.end(lex_root.tag)
-
-    return lex
-
-
-def _load_dependency(events, tag) -> Dict[str, str]:
-    events.start(tag)
-    elem = events.end(tag)
-    return {'id': elem.attrib['id'],
-            'version': elem.attrib['version'],
-            'url': elem.attrib.get('url')}
-
-
-def _load_lexical_entries(
-    events: XMLEventIterator,
-    extension: bool,
-    version: str,
-    lex_root: ET.Element,
-) -> Tuple[List[LexicalEntry], List[SyntacticBehaviour], Dict[str, List[str]]]:
-    entries: List[LexicalEntry] = []
-    syntactic_behaviours: List[SyntacticBehaviour] = []
-    sbmap: Dict[str, List[str]] = {}
-    while True:
-        if events.starts('LexicalEntry'):
-            attrs = events.start('LexicalEntry').attrib
-            entry = LexicalEntry(
-                attrs['id'],
-                _load_lemma(events, False),
-                forms=_load_forms(events, False),
-                senses=_load_senses(events, sbmap, False, version),
-                meta=_get_metadata(attrs, version),
-            )
-            syntactic_behaviours.extend(_load_syntactic_behaviours(events, version))
-            events.end('LexicalEntry')
-        elif extension and events.starts('ExternalLexicalEntry'):
-            attrs = events.start('ExternalLexicalEntry').attrib
-            entry = LexicalEntry.as_external(
-                attrs['id'],
-                lemma=_load_lemma(events, True),
-                forms=_load_forms(events, True),
-                senses=_load_senses(events, sbmap, True, version)
-            )
-            syntactic_behaviours.extend(_load_syntactic_behaviours(events, version))
-            events.end('ExternalLexicalEntry')
-        else:
-            break
-        entries.append(entry)
-        lex_root.clear()
-
-    return entries, syntactic_behaviours, sbmap
-
-
-def _load_lemma(events, external) -> Optional[Lemma]:
-    lemma: Optional[Lemma] = None
-    if external and events.starts('ExternalLemma'):
-        next(events)
-        lemma = Lemma.as_external(
-            pronunciations=_load_pronunciations(events),
-            tags=_load_tags(events)
-        )
-        events.end('ExternalLemma')
-    elif not external or events.starts('Lemma'):
-        attrs = events.start('Lemma').attrib
-        lemma = Lemma(
-            attrs['writtenForm'],
-            _get_literal(attrs['partOfSpeech'], PARTS_OF_SPEECH),
-            script=attrs.get('script'),
-            pronunciations=_load_pronunciations(events),
-            tags=_load_tags(events)
-        )
-        events.end('Lemma')
-    return lemma
-
-
-def _load_forms(events, external) -> List[Form]:
-    forms: List[Form] = []
-    while True:
-        if events.starts('Form'):
-            attrs = next(events)[1].attrib
-            forms.append(
-                Form(attrs.get('id'),
-                     attrs['writtenForm'],
-                     script=attrs.get('script'),
-                     pronunciations=_load_pronunciations(events),
-                     tags=_load_tags(events))
-            )
-            events.end('Form')
-        elif external and events.starts('ExternalForm'):
-            attrs = next(events)[1].attrib
-            forms.append(
-                Form.as_external(
-                    attrs['id'],
-                    pronunciations=_load_pronunciations(events),
-                    tags=_load_tags(events)
-                )
-            )
-            events.end('ExternalForm')
-        else:
-            break
-    return forms
-
-
-def _load_pronunciations(events) -> List[Pronunciation]:
-    pronunciations: List[Pronunciation] = []
-    while events.starts('Pronunciation'):
-        next(events)
-        elem = events.end('Pronunciation')
-        attrs = elem.attrib
-        pronunciations.append(
-            Pronunciation(
-                elem.text,
-                variety=attrs.get('variety'),
-                notation=attrs.get('notation'),
-                phonemic=_get_bool(attrs.get('phonemic', 'true')),
-                audio=attrs.get('audio'),
-            )
-        )
-    return pronunciations
-
-
-def _load_tags(events) -> List[Tag]:
-    tags: List[Tag] = []
-    while events.starts('Tag'):
-        next(events)
-        elem = events.end('Tag')
-        tags.append(Tag(elem.text, elem.attrib['category']))
-    return tags
-
-
-def _load_senses(events, sbmap, external, version) -> List[Sense]:
-    senses: List[Sense] = []
-    while True:
-        if events.starts('Sense'):
-            attrs = events.start('Sense').attrib
-            sense = Sense(
-                attrs['id'],
-                attrs['synset'],
-                relations=_load_sense_relations(events, version),
-                examples=_load_examples(events, version),
-                counts=_load_counts(events, version),
-                lexicalized=_get_bool(attrs.get('lexicalized', 'true')),
-                adjposition=_get_literal(attrs.get('adjposition'), ADJPOSITIONS),
-                meta=_get_metadata(attrs, version),
-            )
-            for sbid in attrs.get('subcat', '').split():
-                sbmap.setdefault(sbid, []).append(attrs['id'])
-            events.end('Sense')
-        elif external and events.starts('ExternalSense'):
-            attrs = events.start('ExternalSense').attrib
-            sense = Sense.as_external(
-                attrs['id'],
-                relations=_load_sense_relations(events, version),
-                examples=_load_examples(events, version),
-                counts=_load_counts(events, version),
-            )
-            events.end('ExternalSense')
-        else:
-            break
-        senses.append(sense)
-    return senses
-
-
-def _load_sense_relations(events, version) -> List[SenseRelation]:
-    relations: List[SenseRelation] = []
-    while events.starts('SenseRelation'):
-        next(events)
-        elem = events.end('SenseRelation')
-        attrs = elem.attrib
-        reltype = attrs['relType']
-        if not (reltype in SENSE_RELATIONS or reltype in SENSE_SYNSET_RELATIONS):
-            raise LMFError(f'invalid sense relation: {reltype}')
-        relations.append(
-            SenseRelation(
-                attrs['target'],
-                reltype,
-                meta=_get_metadata(attrs, version)
-            )
-        )
-    return relations
-
-
-def _load_examples(events, version) -> List[Example]:
-    examples: List[Example] = []
-    while events.starts('Example'):
-        next(events)
-        elem = events.end('Example')
-        attrs = elem.attrib
-        examples.append(
-            Example(
-                elem.text,
-                language=attrs.get('language'),
-                meta=_get_metadata(attrs, version),
-            )
-        )
-    return examples
-
-
-def _load_counts(events, version) -> List[Count]:
-    counts: List[Count] = []
-    while events.starts('Count'):
-        next(events)
-        elem = events.end('Count')
-        attrs = elem.attrib
-        value: int = -1
-        try:
-            value = int(elem.text)
-        except ValueError:
-            warnings.warn(f'count must be an integer: {elem.text}', LMFWarning)
-
-        counts.append(Count(value, meta=_get_metadata(attrs, version)))
-
-    return counts
-
-
-def _load_syntactic_behaviours(events, version) -> List[SyntacticBehaviour]:
-    syntactic_behaviours: List[SyntacticBehaviour] = []
-    while events.starts('SyntacticBehaviour'):
-        next(events)
-        attrs = events.end('SyntacticBehaviour').attrib
-        syntactic_behaviours.append(
-            SyntacticBehaviour(
-                attrs.get('id'),
-                attrs['subcategorizationFrame'],
-                senses=attrs.get('senses', '').split(),
-            )
-        )
-    return syntactic_behaviours
-
-
-def _load_synsets(
-    events: XMLEventIterator,
-    extension: bool,
-    version: str,
-    lex_root: ET.Element,
-) -> List[Synset]:
-    synsets: List[Synset] = []
-    while True:
-        if events.starts('Synset'):
-            attrs = events.start('Synset').attrib
-            synset = Synset(
-                attrs['id'],
-                attrs['ili'],
-                pos=_get_literal(attrs.get('partOfSpeech'), PARTS_OF_SPEECH),
-                definitions=_load_definitions(events, version),
-                ili_definition=_load_ilidefinition(events, version),
-                relations=_load_synset_relations(events, version),
-                examples=_load_examples(events, version),
-                lexicalized=_get_bool(attrs.get('lexicalized', 'true')),
-                members=attrs.get('members', '').split(),
-                lexfile=_get_literal(attrs.get('lexfile'), LEXICOGRAPHER_FILES),
-                meta=_get_metadata(attrs, version),
-            )
-            events.end('Synset')
-        elif extension and events.starts('ExternalSynset'):
-            attrs = events.start('ExternalSynset').attrib
-            synset = Synset.as_external(
-                attrs['id'],
-                definitions=_load_definitions(events, version),
-                relations=_load_synset_relations(events, version),
-                examples=_load_examples(events, version),
-            )
-            events.end('ExternalSynset')
-        else:
-            break
-        synsets.append(synset)
-        lex_root.clear()
-
-    return synsets
-
-
-def _load_definitions(events, version) -> List[Definition]:
-    definitions: List[Definition] = []
-    while events.starts('Definition'):
-        next(events)
-        elem = events.end('Definition')
-        attrs = elem.attrib
-        definitions.append(
-            Definition(
-                elem.text,
-                language=attrs.get('language'),
-                source_sense=attrs.get('sourceSense'),
-                meta=_get_metadata(attrs, version),
-            )
-        )
-    return definitions
-
-
-def _load_ilidefinition(events, version) -> Optional[ILIDefinition]:
-    ili_definition: Optional[ILIDefinition] = None
-    if events.starts('ILIDefinition'):
-        next(events)
-        elem = events.end('ILIDefinition')
-        ili_definition = ILIDefinition(
-            elem.text,
-            meta=_get_metadata(elem.attrib, version),
-        )
-    return ili_definition
-
-
-def _load_synset_relations(events, version: str) -> List[SynsetRelation]:
-    relations: List[SynsetRelation] = []
-    while events.starts('SynsetRelation'):
-        next(events)
-        elem = events.end('SynsetRelation')
-        attrs = elem.attrib
-        reltype = attrs['relType']
-        if reltype not in SYNSET_RELATIONS:
-            raise LMFError(f'invalid synset relation: {reltype}')
-        relations.append(
-            SynsetRelation(
-                attrs['target'],
-                reltype,
-                meta=_get_metadata(attrs, version),
-            )
-        )
-    return relations
-
-
-def _get_bool(value: str) -> bool:
-    return _get_literal(value.lower(), ('true', 'false')) == 'true'
-
-
-def _get_literal(value: Optional[str], choices: Container[str]) -> str:
-    if value is not None and value not in choices:
-        warnings.warn(f'{value!r} is not one of {choices!r}', LMFWarning)
-    return value or ''
-
-
-def _get_metadata(attrs: Dict, version: str) -> Optional[Metadata]:
-    metas = [attrs.get(qname) for qname, _ in _DC_QNAME_PAIRS[version]]
-    metas.append(attrs.get('status'))
-    metas.append(attrs.get('note'))
-    if 'confidenceScore' in attrs:
-        value = attrs['confidenceScore']
-        try:
-            value = float(value)
-            assert 0 <= value <= 1
-        except (ValueError, AssertionError):
-            warnings.warn(f'confidenceScore not between 0 and 1: {value}', LMFWarning)
-        metas.append(value)
-    else:
-        metas.append(None)
-    if any(meta is not None for meta in metas):
-        return Metadata(*metas)
-    else:
-        return None
+    def end(name):
+        nonlocal has_text
+        has_text = False
+        stack.pop()
+        progress.update(force=(name == 'LexicalResource'))
 
+    p.StartElementHandler = start
+    p.EndElementHandler = end
+    p.CharacterDataHandler = char_data
 
-def dump(
-        lexicons: LexicalResource, destination: AnyPath, version: str = '1.0'
-) -> None:
+    return p
+
+
+def _unexpected(name: str, p: xml.parsers.expat.XMLParserType) -> LMFError:
+    return LMFError(f'unexpected element at line {p.CurrentLineNumber}: {name}')
+
+
+# Validation ###########################################################
+
+def _validate(elem: _Elem) -> Union[Lexicon, LexiconExtension]:
+    ext = elem.get('extends')
+    if ext:
+        assert 'id' in ext
+        assert 'version' in ext
+        _validate_lexicon(elem, True)
+        return cast(LexiconExtension, elem)
+    else:
+        _validate_lexicon(elem, False)
+        return cast(Lexicon, elem)
+
+
+def _validate_lexicon(elem: _Elem, extension: bool) -> None:
+    for attr in 'id', 'version', 'label', 'language', 'email', 'license':
+        assert attr in elem, f'<Lexicon> missing required attribute: {attr}'
+    for dep in elem.get('requires', []):
+        assert 'id' in dep
+        assert 'version' in dep
+    _validate_entries(elem.get('entries', []), extension)
+    _validate_synsets(elem.get('synsets', []), extension)
+    _validate_frames(elem.get('frames', []))
+
+
+def _validate_entries(elems: List[_Elem], extension: bool) -> None:
+    for elem in elems:
+        assert 'id' in elem
+        if not extension:
+            assert not elem.get('external')
+        lemma = elem.get('lemma')
+        if not elem.get('external'):
+            assert lemma is not None
+            elem.setdefault('meta')
+        # lemma and forms are the same except for partOfSpeech and id
+        if lemma is not None and not lemma.get('external'):
+            assert 'partOfSpeech' in lemma
+        for form in elem.get('forms', []):
+            assert not form.get('external') or form.get('id')
+        _validate_forms(([lemma] if lemma else []) + elem.get('forms', []), extension)
+        _validate_senses(elem.get('senses', []), extension)
+        _validate_frames(elem.get('frames', []))
+
+
+def _validate_forms(elems: List[_Elem], extension: bool) -> None:
+    for elem in elems:
+        if not extension:
+            assert not elem.get('external')
+        if not elem.get('external'):
+            assert 'writtenForm' in elem
+        for pron in elem.get('pronunciations', []):
+            pron.setdefault('text', '')
+            if pron.get('phonemic'):
+                pron['phonemic'] = False if pron['phonemic'] == 'false' else True
+        for tag in elem.get('tags', []):
+            tag.setdefault('text', '')
+            assert 'category' in tag
+
+
+def _validate_senses(elems: List[_Elem], extension: bool) -> None:
+    for elem in elems:
+        assert 'id' in elem
+        if not extension:
+            assert not elem.get('external')
+        if not elem.get('external'):
+            assert 'synset' in elem
+            elem.setdefault('meta')
+        for rel in elem.get('relations', []):
+            assert 'target' in rel
+            assert 'relType' in rel
+            rel.setdefault('meta')
+        for ex in elem.get('examples', []):
+            ex.setdefault('text', '')
+            ex.setdefault('meta')
+        for cnt in elem.get('counts', []):
+            assert 'text' in cnt
+            cnt['value'] = int(cnt.pop('text'))
+            cnt.setdefault('meta')
+        if elem.get('lexicalized'):
+            elem['lexicalized'] = False if elem['lexicalized'] == 'false' else True
+        if elem.get('subcat'):
+            elem['subcat'] = elem['subcat'].split()
+
+
+def _validate_frames(elems: List[_Elem]) -> None:
+    for elem in elems:
+        assert 'subcategorizationFrame' in elem
+        if elem.get('senses'):
+            elem['senses'] = elem['senses'].split()
+
+
+def _validate_synsets(elems: List[_Elem], extension: bool) -> None:
+    for elem in elems:
+        assert 'id' in elem
+        if not extension:
+            assert not elem.get('external')
+        if not elem.get('external'):
+            assert 'ili' in elem
+            elem.setdefault('meta')
+        for defn in elem.get('definitions', []):
+            defn.setdefault('text', '')
+            defn.setdefault('meta')
+        for rel in elem.get('relations', []):
+            assert 'target' in rel
+            assert 'relType' in rel
+            rel.setdefault('meta')
+        for ex in elem.get('examples', []):
+            ex.setdefault('text', '')
+            ex.setdefault('meta')
+        if elem.get('lexicalized'):
+            elem['lexicalized'] = False if elem['lexicalized'] == 'false' else True
+        if elem.get('members'):
+            elem['members'] = elem['members'].split()
+
+
+def _validate_metadata(elem: _Elem) -> None:
+    if elem.get('confidenceScore'):
+        elem['confidenceScore'] = float(elem['confidenceScore'])
+
+
+# Serialization ########################################################
+
+def dump(resource: LexicalResource, destination: AnyPath) -> None:
     """Write wordnets in the WN-LMF format.
 
     Args:
         lexicons: a list of :class:`Lexicon` objects
     """
-    if version not in _SCHEMAS:
+    version = resource['lmf_version']
+    if version not in SUPPORTED_VERSIONS:
         raise LMFError(f'invalid version: {version}')
     destination = Path(destination).expanduser()
     doctype = _DOCTYPE.format(schema=_SCHEMAS[version])
     dc_uri = _DC_URIS[version]
+    _version = version_info(version)
     with destination.open('wt', encoding='utf-8') as out:
         print(_XMLDECL.decode('utf-8'), file=out)
         print(doctype, file=out)
         print(f'<LexicalResource xmlns:dc="{dc_uri}">', file=out)
-        for lexicon in lexicons:
-            _dump_lexicon(lexicon, out, version)
+        for lexicon in resource['lexicons']:
+            _dump_lexicon(lexicon, out, _version)
         print('</LexicalResource>', file=out)
 
 
-def _dump_lexicon(lexicon: Lexicon, out: TextIO, version: str) -> None:
-    lexicontype = 'LexiconExtension' if lexicon.extends else 'Lexicon'
-    attrib = {
-        'id': lexicon.id,
-        'label': lexicon.label,
-        'language': lexicon.language,
-        'email': lexicon.email,
-        'license': lexicon.license,
-        'version': lexicon.version,
-    }
-    if lexicon.url:
-        attrib['url'] = lexicon.url
-    if lexicon.citation:
-        attrib['citation'] = lexicon.citation
-    if version != '1.0' and lexicon.logo:
-        attrib['logo'] = lexicon.logo
-    attrib.update(_meta_dict(lexicon.meta))
+def _dump_lexicon(
+    lexicon: Union[Lexicon, LexiconExtension],
+    out: TextIO,
+    version: VersionInfo
+) -> None:
+    lexicontype = 'LexiconExtension' if lexicon.get('extends') else 'Lexicon'
+    attrib = _build_lexicon_attrib(lexicon, version)
     attrdelim = '\n' + (' ' * len(f'  <{lexicontype} '))
     attrs = attrdelim.join(
         f'{attr}={quoteattr(str(val))}' for attr, val in attrib.items()
     )
     print(f'  <{lexicontype} {attrs}>', file=out)
 
-    if version != '1.0':
+    if version >= (1, 1):
         if lexicontype == 'LexiconExtension':
-            assert lexicon.extends is not None
-            _dump_dependency(lexicon.extends, 'Extends', out)
-        for req in lexicon.requires:
+            assert lexicon.get('extends')
+            lexicon = cast(LexiconExtension, lexicon)
+            _dump_dependency(lexicon['extends'], 'Extends', out)
+        for req in lexicon.get('requires', []):
             _dump_dependency(req, 'Requires', out)
-    sbmap: Dict[str, List[SyntacticBehaviour]] = {}
-    for sb in lexicon.syntactic_behaviours:
-        for sense_id in sb.senses:
-            sbmap.setdefault(sense_id, []).append(sb)
 
-    for entry in lexicon.lexical_entries:
-        _dump_lexical_entry(entry, out, sbmap, version)
+    for entry in lexicon.get('entries', []):
+        _dump_lexical_entry(entry, out, version)
 
-    for synset in lexicon.synsets:
+    for synset in lexicon.get('synsets', []):
         _dump_synset(synset, out, version)
 
-    if version != '1.0':
-        for sb in lexicon.syntactic_behaviours:
+    if version >= (1, 1):
+        for sb in lexicon.get('frames', []):
             _dump_syntactic_behaviour(sb, out, version)
 
     print(f'  </{lexicontype}>', file=out)
 
 
+def _build_lexicon_attrib(
+    lexicon: Union[Lexicon, LexiconExtension],
+    version: VersionInfo
+) -> Dict[str, str]:
+    attrib = {
+        'id': lexicon['id'],
+        'label': lexicon['label'],
+        'language': lexicon['language'],
+        'email': lexicon['email'],
+        'license': lexicon['license'],
+        'version': lexicon['version'],
+    }
+    if lexicon.get('url'):
+        attrib['url'] = lexicon['url']
+    if lexicon.get('citation'):
+        attrib['citation'] = lexicon['citation']
+    if version >= (1, 1) and lexicon.get('logo'):
+        attrib['logo'] = lexicon['logo']
+    attrib.update(_meta_dict(lexicon.get('meta')))
+    return attrib
+
+
 def _dump_dependency(
-    dep: Dict[str, str], deptype: str, out: TextIO
+    dep: Dependency, deptype: str, out: TextIO
 ) -> None:
     attrib = {'id': dep['id'], 'version': dep['version']}
     if dep.get('url'):
@@ -1084,208 +726,208 @@ def _dump_dependency(
 
 
 def _dump_lexical_entry(
-    entry: LexicalEntry,
+    entry: Union[LexicalEntry, ExternalLexicalEntry],
     out: TextIO,
-    sbmap: Dict[str, List[SyntacticBehaviour]],
-    version: str,
+    version: VersionInfo,
 ) -> None:
-    attrib = {'id': entry.id}
-    if entry.external:
+    frames = []
+    attrib = {'id': entry['id']}
+    if entry.get('external', False):
         elem = ET.Element('ExternalLexicalEntry', attrib=attrib)
+        if entry.get('lemma'):
+            assert entry['lemma'].get('external', False)
+            elem.append(_build_lemma(entry['lemma'], version))
     else:
-        attrib.update(_meta_dict(entry.meta))
+        entry = cast(LexicalEntry, entry)
+        attrib.update(_meta_dict(entry.get('meta')))
         elem = ET.Element('LexicalEntry', attrib=attrib)
-        assert entry.lemma is not None
-    if entry.lemma:
-        elem.append(_build_lemma(entry.lemma, version))
-    elem.extend(_build_form(form, version) for form in entry.forms)
-    elem.extend(_build_sense(sense, sbmap, version) for sense in entry.senses)
-    if version == '1.0':
-        senses = set(sense.id for sense in entry.senses)
-        for sense_id in senses:
-            for sb in sbmap.get(sense_id, []):
-                elem.append(
-                    _build_syntactic_behaviour_1_0(
-                        sb.frame, sorted(senses.intersection(sb.senses))
-                    )
-                )
+        elem.append(_build_lemma(entry['lemma'], version))
+        if version < (1, 1):
+            frames = [_build_syntactic_behaviour(sb, version)
+                      for sb in entry.get('frames', [])]
+    elem.extend([_build_form(form, version) for form in entry.get('forms', [])])
+    elem.extend([_build_sense(sense, version)
+                 for sense in entry.get('senses', [])])
+    elem.extend(frames)
     print(_tostring(elem, 2), file=out)
 
 
-def _build_syntactic_behaviour_1_0(frame: str, senses: List[str]) -> ET.Element:
-    return ET.Element(
-        'SyntacticBehaviour',
-        attrib={'subcategorizationFrame': frame, 'senses': ' '.join(senses)}
-    )
-
-
-def _build_lemma(lemma: Lemma, version: str) -> ET.Element:
-    if lemma.external:
+def _build_lemma(
+    lemma: Union[Lemma, ExternalLemma],
+    version: VersionInfo
+) -> ET.Element:
+    if lemma.get('external', False):
         elem = ET.Element('ExternalLemma')
     else:
-        attrib = {'writtenForm': lemma.form}
-        if lemma.script:
-            attrib['script'] = lemma.script
-        attrib['partOfSpeech'] = lemma.pos
+        lemma = cast(Lemma, lemma)
+        attrib = {'writtenForm': lemma['writtenForm']}
+        if lemma.get('script'):
+            attrib['script'] = lemma['script']
+        attrib['partOfSpeech'] = lemma['partOfSpeech']
         elem = ET.Element('Lemma', attrib=attrib)
-    if version != '1.0':
-        for pron in lemma.pronunciations:
+    if version >= (1, 1):
+        for pron in lemma.get('pronunciations', []):
             elem.append(_build_pronunciation(pron))
-    for tag in lemma.tags:
+    for tag in lemma.get('tags', []):
         elem.append(_build_tag(tag))
     return elem
 
 
-def _build_form(form: Form, version: str) -> ET.Element:
+def _build_form(form: Union[Form, ExternalForm], version: VersionInfo) -> ET.Element:
     attrib = {}
-    if version != '1.0' and form.id:
-        attrib['id'] = form.id
-    if form.external:
+    if version >= (1, 1) and form['id']:
+        attrib['id'] = form['id']
+    if form.get('external', False):
         elem = ET.Element('ExternalForm', attrib=attrib)
     else:
-        attrib['writtenForm'] = form.form
-        if form.script:
-            attrib['script'] = form.script
+        form = cast(Form, form)
+        attrib['writtenForm'] = form['writtenForm']
+        if form.get('script'):
+            attrib['script'] = form['script']
         elem = ET.Element('Form', attrib=attrib)
-    if version != '1.0':
-        for pron in form.pronunciations:
+    if version >= (1, 1):
+        for pron in form.get('pronunciations', []):
             elem.append(_build_pronunciation(pron))
-    for tag in form.tags:
+    for tag in form.get('tags', []):
         elem.append(_build_tag(tag))
     return elem
 
 
-def _build_pronunciation(pron) -> ET.Element:
+def _build_pronunciation(pron: Pronunciation) -> ET.Element:
     attrib = {}
-    if pron.variety:
-        attrib['variety'] = pron.variety
-    if pron.notation:
-        attrib['notation'] = pron.notation
-    if not pron.phonemic:
+    if pron.get('variety'):
+        attrib['variety'] = pron['variety']
+    if pron.get('notation'):
+        attrib['notation'] = pron['notation']
+    if not pron.get('phonemic', True):
         attrib['phonemic'] = 'false'
-    if pron.audio:
-        attrib['audio'] = pron.audio
+    if pron.get('audio'):
+        attrib['audio'] = pron['audio']
     elem = ET.Element('Pronunciation', attrib=attrib)
-    elem.text = pron.value
+    elem.text = pron['text']
     return elem
 
 
 def _build_tag(tag: Tag) -> ET.Element:
-    elem = ET.Element('Tag', category=tag.category)
-    elem.text = tag.text
+    elem = ET.Element('Tag', category=tag['category'])
+    elem.text = tag['text']
     return elem
 
 
 def _build_sense(
-    sense: Sense,
-    sbmap: Dict[str, List[SyntacticBehaviour]],
-    version: str,
+    sense: Union[Sense, ExternalSense],
+    version: VersionInfo,
 ) -> ET.Element:
-    attrib = {'id': sense.id}
-    if sense.external:
+    attrib = {'id': sense['id']}
+    if sense.get('external'):
         elem = ET.Element('ExternalSense', attrib=attrib)
     else:
-        attrib['synset'] = sense.synset
-        attrib.update(_meta_dict(sense.meta))
-        if not sense.lexicalized:
+        sense = cast(Sense, sense)
+        attrib['synset'] = sense['synset']
+        attrib.update(_meta_dict(sense.get('meta')))
+        if not sense.get('lexicalized', True):
             attrib['lexicalized'] = 'false'
-        if sense.adjposition:
-            attrib['adjposition'] = sense.adjposition
-        if version != '1.0' and sense.id in sbmap:
-            attrib['subcat'] = ' '.join(sb.id for sb in sbmap[sense.id] if sb.id)
+        if sense.get('adjposition'):
+            attrib['adjposition'] = sense['adjposition']
+        if version >= (1, 1) and sense.get('subcat'):
+            attrib['subcat'] = ' '.join(sense['subcat'])
         elem = ET.Element('Sense', attrib=attrib)
-    elem.extend(_build_sense_relation(rel) for rel in sense.relations)
-    elem.extend(_build_example(ex) for ex in sense.examples)
-    elem.extend(_build_count(cnt) for cnt in sense.counts)
+    elem.extend([_build_relation(rel, 'SenseRelation')
+                 for rel in sense.get('relations', [])])
+    elem.extend([_build_example(ex) for ex in sense.get('examples', [])])
+    elem.extend([_build_count(cnt) for cnt in sense.get('counts', [])])
     return elem
 
 
-def _build_sense_relation(relation: SenseRelation) -> ET.Element:
-    attrib = {'target': relation.target, 'relType': relation.type}
-    attrib.update(_meta_dict(relation.meta))
-    return ET.Element('SenseRelation', attrib=attrib)
-
-
 def _build_example(example: Example) -> ET.Element:
     elem = ET.Element('Example')
-    elem.text = example.text
-    if example.language:
-        elem.set('language', example.language)
+    elem.text = example['text']
+    if example.get('language'):
+        elem.set('language', example['language'])
     return elem
 
 
 def _build_count(count: Count) -> ET.Element:
-    elem = ET.Element('Count', attrib=_meta_dict(count.meta))
-    elem.text = str(count.value)
+    elem = ET.Element('Count', attrib=_meta_dict(count.get('meta')))
+    elem.text = str(count['value'])
     return elem
 
 
-def _dump_synset(synset: Synset, out: TextIO, version: str) -> None:
-    attrib: Dict[str, str] = {'id': synset.id}
-    if synset.external:
+def _dump_synset(
+    synset: Union[Synset, ExternalSynset],
+    out: TextIO,
+    version: VersionInfo
+) -> None:
+    attrib: Dict[str, str] = {'id': synset['id']}
+    if synset.get('external', False):
         elem = ET.Element('ExternalSynset', attrib=attrib)
+        elem.extend([_build_definition(defn) for defn in synset.get('definitions', [])])
     else:
-        attrib['ili'] = synset.ili or ''
-        if synset.pos:
-            attrib['partOfSpeech'] = synset.pos
-        if not synset.lexicalized:
+        synset = cast(Synset, synset)
+        attrib['ili'] = synset['ili']
+        if synset.get('partOfSpeech'):
+            attrib['partOfSpeech'] = synset['partOfSpeech']
+        if not synset.get('lexicalized', True):
             attrib['lexicalized'] = 'false'
-        if version != '1.0':
-            if synset.members:
-                attrib['members'] = ' '.join(synset.members)
-            if synset.lexfile:
-                attrib['lexfile'] = synset.lexfile
-        attrib.update(_meta_dict(synset.meta))
+        if version >= (1, 1):
+            if synset.get('members'):
+                attrib['members'] = ' '.join(synset['members'])
+            if synset.get('lexfile'):
+                attrib['lexfile'] = synset['lexfile']
+        attrib.update(_meta_dict(synset.get('meta')))
         elem = ET.Element('Synset', attrib=attrib)
-    elem.extend(_build_definition(defn) for defn in synset.definitions)
-    if synset.ili_definition and not synset.external:
-        elem.append(_build_ili_definition(synset.ili_definition))
-    elem.extend(_build_synset_relation(rel) for rel in synset.relations)
-    elem.extend(_build_example(ex) for ex in synset.examples)
+        elem.extend([_build_definition(defn) for defn in synset.get('definitions', [])])
+        if synset.get('ili_definition'):
+            elem.append(_build_ili_definition(synset['ili_definition']))
+    elem.extend([_build_relation(rel, 'SynsetRelation')
+                 for rel in synset.get('relations', [])])
+    elem.extend([_build_example(ex) for ex in synset.get('examples', [])])
     print(_tostring(elem, 2), file=out)
 
 
 def _build_definition(definition: Definition) -> ET.Element:
     attrib = {}
-    if definition.language:
-        attrib['language'] = definition.language
-    if definition.source_sense:
-        attrib['sourceSense'] = definition.source_sense
-    attrib.update(_meta_dict(definition.meta))
+    if definition.get('language'):
+        attrib['language'] = definition['language']
+    if definition.get('sourceSense'):
+        attrib['sourceSense'] = definition['sourceSense']
+    attrib.update(_meta_dict(definition.get('meta')))
     elem = ET.Element('Definition', attrib=attrib)
-    elem.text = definition.text
+    elem.text = definition['text']
     return elem
 
 
 def _build_ili_definition(ili_definition: ILIDefinition) -> ET.Element:
-    elem = ET.Element('ILIDefinition', attrib=_meta_dict(ili_definition.meta))
-    elem.text = ili_definition.text
+    elem = ET.Element('ILIDefinition', attrib=_meta_dict(ili_definition.get('meta')))
+    elem.text = ili_definition['text']
     return elem
 
 
-def _build_synset_relation(relation: SynsetRelation) -> ET.Element:
-    attrib = {'target': relation.target, 'relType': relation.type}
-    attrib.update(_meta_dict(relation.meta))
-    return ET.Element('SynsetRelation', attrib=attrib)
+def _build_relation(relation: Relation, elemtype: str) -> ET.Element:
+    attrib = {'target': relation['target'], 'relType': relation['relType']}
+    attrib.update(_meta_dict(relation.get('meta')))
+    return ET.Element(elemtype, attrib=attrib)
 
 
 def _dump_syntactic_behaviour(
-        syntactic_behaviour: SyntacticBehaviour, out: TextIO, version: str
+    syntactic_behaviour: SyntacticBehaviour,
+    out: TextIO,
+    version: VersionInfo
 ) -> None:
-    elem = _build_syntactic_behaviour(syntactic_behaviour, version=version)
+    elem = _build_syntactic_behaviour(syntactic_behaviour, version)
     print('    ' + _tostring(elem, 2), file=out)
 
 
 def _build_syntactic_behaviour(
-    syntactic_behaviour: SyntacticBehaviour, version: str
+    syntactic_behaviour: SyntacticBehaviour,
+    version: VersionInfo
 ) -> ET.Element:
-    elem = ET.Element('SyntacticBehaviour')
-    if version != '1.0' and syntactic_behaviour.id:
-        elem.set('id', syntactic_behaviour.id)
-    elem.set('subcategorizationFrame', syntactic_behaviour.frame)
-    if version == '1.0' and syntactic_behaviour.senses:
-        elem.set('senses', ' '.join(syntactic_behaviour.senses))
-    return elem
+    attrib = {'subcategorizationFrame': syntactic_behaviour['subcategorizationFrame']}
+    if version >= (1, 1) and syntactic_behaviour.get('id'):
+        attrib['id'] = syntactic_behaviour['id']
+    elif version < (1, 1) and syntactic_behaviour.get('senses'):
+        attrib['senses'] = ' '.join(syntactic_behaviour['senses'])
+    return ET.Element('SyntacticBehaviour', attrib=attrib)
 
 
 def _tostring(
@@ -1312,17 +954,32 @@ def _indent(elem: ET.Element, level: int) -> None:
         elem[-1].tail = self_indent
 
 
-def _meta_dict(m: Optional[Metadata]) -> Dict[str, str]:
-    if m:
-        d = {f'dc:{key}': str(getattr(m, key))
-             for key in _DC_ATTRS
-             if getattr(m, key, None) is not None}
-        if m.status is not None:
-            d['status'] = m.status
-        if m.note is not None:
-            d['note'] = m.note
-        if m.confidence is not None:
-            d['confidenceScore'] = str(m.confidence)
+def _meta_dict(meta: Optional[Metadata]) -> Dict[str, str]:
+    if meta is not None:
+        # Literal keys are required for typing purposes, so first
+        # construct the dict and then remove those that weren't specified.
+        d = {
+            'dc:contributor': meta.get('contributor', ''),
+            'dc:coverage': meta.get('coverage', ''),
+            'dc:creator': meta.get('creator', ''),
+            'dc:date': meta.get('date', ''),
+            'dc:description': meta.get('description', ''),
+            'dc:format': meta.get('format', ''),
+            'dc:identifier': meta.get('identifier', ''),
+            'dc:publisher': meta.get('publisher', ''),
+            'dc:relation': meta.get('relation', ''),
+            'dc:rights': meta.get('rights', ''),
+            'dc:source': meta.get('source', ''),
+            'dc:subject': meta.get('subject', ''),
+            'dc:title': meta.get('title', ''),
+            'dc:type': meta.get('type', ''),
+            'status': meta.get('status', ''),
+            'note': meta.get('note', ''),
+        }
+        d = {key: val for key, val in d.items() if val}
+        # this one requires a conversion, so do it separately
+        if 'confidenceScore' in meta:
+            d['confidenceScore'] = str(meta['confidenceScore'])
     else:
         d = {}
     return d
diff --git a/wn/validate.py b/wn/validate.py
new file mode 100644
index 0000000..68ad130
--- /dev/null
+++ b/wn/validate.py
@@ -0,0 +1,301 @@
+
+"""Wordnet lexicon validation.
+
+This module is for checking whether the the contents of a lexicon are
+valid according to a series of checks. Those checks are:
+
+====  ==========================================================
+Code  Message
+====  ==========================================================
+E101  ID is not unique within the lexicon.
+W201  Lexical entry has no senses.
+W202  Redundant sense between lexical entry and synset.
+W203  Redundant lexical entry with the same lemma and synset.
+E204  Synset of sense is missing.
+W301  Synset is empty (not associated with any lexical entries).
+W302  ILI is repeated across synsets.
+W303  Proposed ILI is missing a definition.
+W304  Existing ILI has a spurious definition.
+E401  Relation target is missing or invalid.
+W402  Relation type is invalid for the source and target.
+W403  Redundant relation between source and target.
+W404  Reverse relation is missing.
+W501  Synset's part-of-speech is different from its hypernym's.
+W502  Relation is a self-loop.
+====  ==========================================================
+
+"""
+
+from typing import (Sequence, Iterator, Tuple, List, Dict,
+                    Optional, Type, Union, Callable, cast)
+from collections import Counter
+from itertools import chain
+
+from wn import lmf
+from wn.constants import (
+    SENSE_RELATIONS,
+    SENSE_SYNSET_RELATIONS,
+    SYNSET_RELATIONS,
+    REVERSE_RELATIONS,
+)
+from wn.util import ProgressHandler, ProgressBar
+
+
+_Ids = Dict[str, Counter]
+_Result = Dict[str, Dict]
+_CheckFunction = Callable[[lmf.Lexicon, _Ids], _Result]
+_Report = Dict[str, Dict[str, Union[str, _Result]]]
+
+
+def _non_unique_id(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """ID is not unique within the lexicon"""
+    return _multiples(chain(
+        [lex['id']],
+        (f['id'] for e in _entries(lex) for f in _forms(e) if f.get('id')),
+        (sb['id'] for sb in lex.get('frames', []) if sb.get('id')),
+        ids['entry'],
+        ids['sense'],
+        ids['synset'],
+    ))
+
+
+def _has_no_senses(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """lexical entry has no senses"""
+    return {e['id']: {} for e in _entries(lex) if not _senses(e)}
+
+
+def _redundant_sense(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """redundant sense between lexical entry and synset"""
+    result: _Result = {}
+    for e in _entries(lex):
+        redundant = _multiples(s['synset'] for s in _senses(e))
+        result.update((s['id'], {'entry': e['id'], 'synset': s['synset']})
+                      for s in _senses(e)
+                      if s['synset'] in redundant)
+    return result
+
+
+def _redundant_entry(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """redundant lexical entry with the same lemma and synset"""
+    redundant = _multiples((e['lemma']['writtenForm'], s['synset'])
+                           for e in _entries(lex)
+                           for s in _senses(e))
+    return {form: {'synset': synset} for form, synset in redundant}
+
+
+def _missing_synset(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """synset of sense is missing"""
+    synset_ids = ids['synset']
+    return {s['id']: {'synset': s['synset']}
+            for e in _entries(lex)
+            for s in _senses(e)
+            if s['synset'] not in synset_ids}
+
+
+def _empty_synset(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """synset is empty (not associated with any lexical entries)"""
+    synsets = {s['synset'] for e in _entries(lex) for s in _senses(e)}
+    return {ss['id']: {} for ss in _synsets(lex) if ss['id'] not in synsets}
+
+
+def _repeated_ili(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """ILI is repeated across synsets"""
+    repeated = _multiples(
+        ss['ili'] for ss in _synsets(lex) if ss['ili'] and ss['ili'] != 'in'
+    )
+    return {ss['id']: {'ili': ss['ili']}
+            for ss in _synsets(lex)
+            if ss['ili'] in repeated}
+
+
+def _missing_ili_definition(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """proposed ILI is missing a definition"""
+    return {ss['id']: {} for ss in _synsets(lex)
+            if ss['ili'] == 'in' and not ss.get('ili_definition')}
+
+
+def _spurious_ili_definition(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """existing ILI has a spurious definition"""
+    return {ss['id']: {'ili_definitin': ss['ili_definition']}
+            for ss in _synsets(lex)
+            if ss['ili'] and ss['ili'] != 'in' and ss.get('ili_definition')}
+
+
+def _missing_relation_target(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """relation target is missing or invalid"""
+    result = {s['id']: {'type': r['relType'], 'target': r['target']}
+              for s, r in _sense_relations(lex)
+              if r['target'] not in ids['sense'] and r['target'] not in ids['synset']}
+    result.update((ss['id'], {'type': r['relType'], 'target': r['target']})
+                  for ss, r in _synset_relations(lex)
+                  if r['target'] not in ids['synset'])
+    return result
+
+
+def _invalid_relation_type(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """relation type is invalid for the source and target"""
+    result = {s['id']: {'type': r['relType'], 'target': r['target']}
+              for s, r in _sense_relations(lex)
+              if (r['target'] in ids['sense']
+                  and r['relType'] not in SENSE_RELATIONS)
+              or (r['target'] in ids['synset']
+                  and r['relType'] not in SENSE_SYNSET_RELATIONS)}
+    result.update((ss['id'], {'type': r['relType'], 'target': r['target']})
+                  for ss, r in _synset_relations(lex)
+                  if r['relType'] not in SYNSET_RELATIONS)
+    return result
+
+
+def _redundant_relation(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """redundant relation between source and target"""
+    redundant = _multiples(chain(
+        ((s['id'], r['relType'], r['target']) for s, r in _sense_relations(lex)),
+        ((ss['id'], r['relType'], r['target']) for ss, r in _synset_relations(lex)),
+    ))
+    return {src: {'type': typ, 'target': tgt} for src, typ, tgt in redundant}
+
+
+def _missing_reverse_relation(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """reverse relation is missing"""
+    regular = {(s['id'], r['relType'], r['target'])
+               for s, r in _sense_relations(lex)
+               if r['target'] in ids['sense']}
+    regular.update((ss['id'], r['relType'], r['target'])
+                   for ss, r in _synset_relations(lex))
+    return {tgt: {'type': REVERSE_RELATIONS[typ], 'target': src}
+            for src, typ, tgt in regular
+            if typ in REVERSE_RELATIONS
+            and (tgt, REVERSE_RELATIONS[typ], src) not in regular}
+
+
+def _hypernym_wrong_pos(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """synset's part-of-speech is different from its hypernym's"""
+    sspos = {ss['id']: ss.get('partOfSpeech') for ss in _synsets(lex)}
+    return {ss['id']: {'type': r['relType'], 'target': r['target']}
+            for ss, r in _synset_relations(lex)
+            if r['relType'] == 'hypernym'
+            and ss.get('partOfSpeech') != sspos[r['target']]}
+
+
+def _self_loop(lex: lmf.Lexicon, ids: _Ids) -> _Result:
+    """relation is a self-loop"""
+    relations = chain(_sense_relations(lex), _synset_relations(lex))
+    return {x['id']: {'type': r['relType'], 'target': r['target']}
+            for x, r in relations
+            if x['id'] == r['target']}
+
+
+# Helpers
+
+def _multiples(iterable):
+    counts = Counter(iterable)
+    return {x: {'count': cnt} for x, cnt in counts.items() if cnt > 1}
+
+
+def _entries(lex: lmf.Lexicon) -> List[lmf.LexicalEntry]: return lex.get('entries', [])
+def _forms(e: lmf.LexicalEntry) -> List[lmf.Form]: return e.get('forms', [])
+def _senses(e: lmf.LexicalEntry) -> List[lmf.Sense]: return e.get('senses', [])
+def _synsets(lex: lmf.Lexicon) -> List[lmf.Synset]: return lex.get('synsets', [])
+
+
+def _sense_relations(lex: lmf.Lexicon) -> Iterator[Tuple[lmf.Sense, lmf.Relation]]:
+    for e in _entries(lex):
+        for s in _senses(e):
+            for r in s.get('relations', []):
+                yield (s, r)
+
+
+def _synset_relations(lex: lmf.Lexicon) -> Iterator[Tuple[lmf.Synset, lmf.Relation]]:
+    for ss in _synsets(lex):
+        for r in ss.get('relations', []):
+            yield (ss, r)
+
+
+# Check codes and messages
+#
+# categories:
+#   E - errors
+#   W - warnings
+# subcategories:
+#   100 - general
+#   200 - words and senses
+#   300 - synsets and ilis
+#   400 - relations
+#   500 - graph and taxonomy
+
+_codes: Dict[str, _CheckFunction] = {
+    # 100 - general
+    'E101': _non_unique_id,
+    # 200 - words and senses
+    'W201': _has_no_senses,
+    'W202': _redundant_sense,
+    'W203': _redundant_entry,
+    'E204': _missing_synset,
+    # 300 - synsets and ilis
+    'W301': _empty_synset,
+    'W302': _repeated_ili,
+    'W303': _missing_ili_definition,
+    'W304': _spurious_ili_definition,
+    # 400 - relations
+    'E401': _missing_relation_target,
+    'W402': _invalid_relation_type,
+    'W403': _redundant_relation,
+    'W404': _missing_reverse_relation,
+    # 500 - graph
+    'W501': _hypernym_wrong_pos,
+    'W502': _self_loop,
+}
+
+
+def _select_checks(select: Sequence[str]) -> List[Tuple[str, _CheckFunction, str]]:
+    selectset = set(select)
+    return [(code, func, func.__doc__ or '')
+            for code, func in _codes.items()
+            if code in selectset or code[0] in selectset]
+
+
+# Main function
+
+def validate(
+    lex: Union[lmf.Lexicon, lmf.LexiconExtension],
+    select: Sequence[str] = ('E', 'W'),
+    progress_handler: Optional[Type[ProgressHandler]] = ProgressBar
+) -> _Report:
+    """Check *lex* for validity and return a report of the results.
+
+    The *select* argument is a sequence of check codes (e.g.,
+    ``E101``) or categories (``E`` or ``W``).
+
+    The *progress_handler* parameter takes a subclass of
+    :class:`wn.util.ProgressHandler`. An instance of the class will be
+    created, used, and closed by this function.
+    """
+    if lex.get('extends'):
+        print('validation of lexicon extensions is not supported')
+        return {}
+    lex = cast(lmf.Lexicon, lex)
+
+    if progress_handler is None:
+        progress_handler = ProgressHandler
+
+    ids: _Ids = {
+        'entry': Counter(entry['id'] for entry in _entries(lex)),
+        'sense': Counter(sense['id']
+                         for entry in _entries(lex)
+                         for sense in _senses(entry)),
+        'synset': Counter(synset['id'] for synset in _synsets(lex)),
+    }
+
+    checks = _select_checks(select)
+
+    progress = progress_handler(message='Validate', total=len(checks))
+
+    report: _Report = {}
+    for code, func, message in checks:
+        progress.set(status=func.__name__.replace('_', ' '))
+        report[code] = {'message': message,
+                        'items': func(lex, ids)}
+        progress.update()
+    progress.set(status='')
+    progress.close()
+    return report


Test patch:
diff --git a/tests/lmf_test.py b/tests/lmf_test.py
index b6c1205..59180ea 100644
--- a/tests/lmf_test.py
+++ b/tests/lmf_test.py
@@ -13,57 +13,62 @@ def test_is_lmf(datadir):
 
 
 def test_load_1_0(mini_lmf_1_0):
-    lexicons = lmf.load(mini_lmf_1_0)
+    resource = lmf.load(mini_lmf_1_0)
+    lexicons = resource['lexicons']
     assert len(lexicons) == 2
     lexicon = lexicons[0]
 
-    assert lexicon.id == 'test-en'
-    assert lexicon.label == 'Testing English WordNet'
-    assert lexicon.language == 'en'
-    assert lexicon.email == 'maintainer@example.com'
-    assert lexicon.license == 'https://creativecommons.org/licenses/by/4.0/'
-    assert lexicon.version == '1'
-    assert lexicon.url == 'https://example.com/test-en'
+    assert lexicon['id'] == 'test-en'
+    assert lexicon['label'] == 'Testing English WordNet'
+    assert lexicon['language'] == 'en'
+    assert lexicon['email'] == 'maintainer@example.com'
+    assert lexicon['license'] == 'https://creativecommons.org/licenses/by/4.0/'
+    assert lexicon['version'] == '1'
+    assert lexicon['url'] == 'https://example.com/test-en'
 
-    assert len(lexicon.lexical_entries) == 9
-    le = lexicon.lexical_entries[0]
-    assert le.id == 'test-en-information-n'
+    assert len(lexicon['entries']) == 9
+    le = lexicon['entries'][0]
+    assert le['id'] == 'test-en-information-n'
 
-    assert le.lemma.form == 'information'
-    assert le.lemma.pos == 'n'
-    assert le.lemma.script == 'Latn'
-    assert len(le.lemma.tags) == 1
+    assert le['lemma']['writtenForm'] == 'information'
+    assert le['lemma']['partOfSpeech'] == 'n'
+    assert le['lemma']['script'] == 'Latn'
+    assert len(le['lemma']['tags']) == 1
 
-    assert len(le.forms) == 0
+    assert len(le.get('forms', [])) == 0
 
-    assert len(le.senses) == 1
-    assert le.senses[0].id == 'test-en-information-n-0001-01'
-    assert le.senses[0].synset == 'test-en-0001-n'
-    assert len(le.senses[0].relations) == 0
-    # assert le.senses[0].relations[0].target == 'test-en-exemplify-v-01023137-01'
-    # assert le.senses[0].relations[0].type == 'derivation'
+    assert len(le['senses']) == 1
+    sense = le['senses'][0]
+    assert sense['id'] == 'test-en-information-n-0001-01'
+    assert sense['synset'] == 'test-en-0001-n'
+    assert len(sense.get('relations', [])) == 0
+    # assert sense['relations'][0]['target'] == 'test-en-exemplify-v-01023137-01'
+    # assert sense['relations'][0]['type'] == 'derivation'
 
-    assert len(lexicon.syntactic_behaviours) == 2
-    assert lexicon.syntactic_behaviours[0].frame == 'Somebody ----s something'
-    assert lexicon.syntactic_behaviours[0].senses == ['test-en-illustrate-v-0004-01']
+    assert len(lexicon.get('frames', [])) == 0  # frames are on lexical entry
+    assert len(lexicon['entries'][6]['frames']) == 2
+    frames = lexicon['entries'][6]['frames']
+    assert frames[0]['subcategorizationFrame'] == 'Somebody ----s something'
+    assert frames[0]['senses'] == ['test-en-illustrate-v-0004-01']
 
-    assert len(lexicon.synsets) == 8
+    assert len(lexicon['synsets']) == 8
 
-    assert lexicons[1].id == 'test-es'
+    assert lexicons[1]['id'] == 'test-es'
 
 
 def test_load_1_1(mini_lmf_1_1):
-    lexicons = lmf.load(mini_lmf_1_1)
+    resource = lmf.load(mini_lmf_1_1)
+    lexicons = resource['lexicons']
     assert len(lexicons) == 2
     lexicon = lexicons[0]
-    assert lexicon.id == 'test-ja'
-    assert lexicon.version == '1'
+    assert lexicon['id'] == 'test-ja'
+    assert lexicon['version'] == '1'
     # assert lexicon.logo == 'logo.svg'
-    assert lexicon.requires == [{'id': 'test-en', 'version': '1', 'url': None}]
+    assert lexicon.get('requires') == [{'id': 'test-en', 'version': '1'}]
 
     lexicon = lexicons[1]
-    assert lexicon.id == 'test-en-ext'
-    assert lexicon.extends == {'id': 'test-en', 'version': '1', 'url': None}
+    assert lexicon['id'] == 'test-en-ext'
+    assert lexicon.get('extends') == {'id': 'test-en', 'version': '1'}
 
 
 def test_dump(mini_lmf_1_0, mini_lmf_1_1, tmp_path):
@@ -80,8 +85,8 @@ def assert_xml_equal(mini_lmf, dump_lmf):
             temp = temp.replace('<', '\n<')
             assert orig == temp
 
-    lmf.dump(lmf.load(mini_lmf_1_0), tmppath, version='1.0')
+    lmf.dump(lmf.load(mini_lmf_1_0), tmppath)
     assert_xml_equal(mini_lmf_1_0, tmppath)
 
-    lmf.dump(lmf.load(mini_lmf_1_1), tmppath, version='1.1')
+    lmf.dump(lmf.load(mini_lmf_1_1), tmppath)
     assert_xml_equal(mini_lmf_1_1, tmppath)
diff --git a/tests/morphy_test.py b/tests/morphy_test.py
index 64baa92..5390b79 100644
--- a/tests/morphy_test.py
+++ b/tests/morphy_test.py
@@ -38,3 +38,14 @@ def test_morphy_initialized():
     assert m('examples', None) == {'n': {'example'}}
     assert m('exemplifying', None) == {'v': {'exemplify'}}
     assert m('data', None) == {'n': {'datum'}}
+
+
+@pytest.mark.usefixtures('mini_db')
+def test_issue_154():
+    # https://github.com/goodmami/wn/issues/154
+    w = wn.Wordnet('test-en:1')
+    assert w.words('exemplifies') == [w.word('test-en-exemplify-v')]
+    assert w.words('samples') == []
+    w = wn.Wordnet('test-en:1', lemmatizer=morphy.Morphy())
+    assert w.words('exemplifies') == [w.word('test-en-exemplify-v')]
+    assert w.words('samples') == [w.word('test-en-sample-n')]
diff --git a/tests/primary_query_test.py b/tests/primary_query_test.py
index 58f4021..15bdef0 100644
--- a/tests/primary_query_test.py
+++ b/tests/primary_query_test.py
@@ -19,10 +19,10 @@ def test_lexicons_mini():
     results = wn.lexicons(lang='es')
     assert len(results) == 1 and results[0].language == 'es'
 
-    results = wn.lexicons(lexicon='*')
-    assert len(results) == 2
-    results = wn.lexicons(lexicon='*:1')
-    assert len(results) == 2
+    assert len(wn.lexicons(lexicon='*')) == 2
+    assert len(wn.lexicons(lexicon='*:1')) == 2
+    assert len(wn.lexicons(lexicon='test-*')) == 2
+    assert len(wn.lexicons(lexicon='*-en')) == 1
     results = wn.lexicons(lexicon='test-en')
     assert len(results) == 1 and results[0].language == 'en'
     results = wn.lexicons(lexicon='test-en:1')
@@ -127,6 +127,8 @@ def test_senses_mini():
     senses = wn.senses('exemplifies')  # search secondary form
     assert len(senses) == 1
     assert senses[0].word().lemma() == 'exemplify'
+    assert senses[0].word().lemma() in {'exemplify'}
+    assert 'exemplify' in {senses[0].word().lemma()}
 
     assert len(wn.senses(pos='n')) == 11
     assert len(wn.senses(pos='v')) == 5